Skip to main content

yggdrasil/components/assets/
asset_upload.rs

1//! 素材上传 modal(素材管理页内上传)。
2//!
3//! 三条入口(点击选择 / 拖拽 / 粘贴)收敛到同一个 `enqueue_files`:拿到的
4//! `web_sys::File` 先过 `validate_file`(镜像服务端 5MiB / 四种 MIME 的硬限制,
5//! 不合格立即记失败行、不发请求),合格项入共享队列后由 **worker 池**并发上传:
6//! 在跑 worker 数上限 = 并发配置(「站点配置」面板 / `UPLOAD_CONCURRENCY` env 播种,
7//! 挂载时经 `get_upload_settings` 拉取,失败回退默认 3)。每个 worker 张间停顿
8//! `500ms × 当前并发数`——N 路并行时聚合速率恒 ≤ 2/s,与默认上传限流桶
9//! (`RATE_LIMIT_UPLOAD_PER_SEC=2` / `BURST=15`)对齐:N=1 时与旧顺序版逐张 500ms
10//! 完全一致,N 调大也不触发 429。上传管线与文章编辑器完全同源
11//! (`upload_image_file` → `POST /api/upload`)。
12//!
13//! Esc / 粘贴监听挂在 window 上,只在 mount 注册一次、`use_drop` 移除;handler 内用
14//! `visible.peek()` 守卫——modal 关闭后粘贴绝不触发上传。无文件的文本粘贴不拦截
15//! (不 prevent_default),搜索框等正常粘贴不受影响。原生监听器触发时 Dioxus 的
16//! scope 栈为空(`spawn` 会因 `current_scope_id` unwrap 空栈而 panic),渲染期捕获的
17//! 组件 scope id 在粘贴 handler 里经 `Runtime::in_scope` 显式进栈后再入队,使粘贴路径
18//! 与 Dioxus 事件入口(拖拽/选择)的 spawn 语义完全一致。
19//!
20//! 组件始终挂载(`visible` 只控制渲染早退),上传中途允许关闭弹窗:spawn 的 worker 与
21//! signals 随组件实例存活,后台续传,每批(一次拖拽/选择/粘贴)收尾时 ≥1 个成功仍照常
22//! 回调 `on_uploaded`。
23//!
24//! 注:worker 池共享状态用 `Rc<UploadPool>` 而非裸 Cell/RefCell——`use_hook` 每次渲染
25//! 都会 clone 存储值(dioxus-core `use_hook_inner` 走 `.cloned()`),裸结构会被按值
26//! 拷贝,各入口闭包拿到互不同步的副本导致 id 冲突与队列分裂;Rc 克隆共享同一实例。
27
28use dioxus::prelude::*;
29
30use crate::components::ui::SPINNER_SVG;
31
32#[cfg(target_arch = "wasm32")]
33use crate::bridges::tiptap::upload_image_file;
34#[cfg(target_arch = "wasm32")]
35use crate::utils::format_bytes;
36// 从 Dioxus 事件拿底层 web_sys::File(write.rs L30-34 同款 cfg 门控惯例):
37// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)
38// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)
39#[cfg(target_arch = "wasm32")]
40use dioxus::html::HasFileData;
41#[cfg(target_arch = "wasm32")]
42use dioxus::web::WebFileExt;
43
44/// 单文件大小硬上限(5MiB),镜像服务端 `crate::utils::server::MAX_FILE_SIZE`。
45#[cfg(any(test, target_arch = "wasm32"))]
46const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
47/// 允许的 MIME 白名单,镜像服务端 `api/upload.rs` 的 `ALLOWED_MIME_TYPES`。
48#[cfg(any(test, target_arch = "wasm32"))]
49const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
50
51/// 关闭/移除动画时长 ms,与 input.css 的 200ms 过渡/动画时长一一对应。
52const EXIT_ANIM_MS: u32 = 200;
53
54/// 单条上传状态机:Queued → Uploading → Done / Failed(原因)。
55// 变体仅在 WASM 端构造,server SSR 只匹配渲染,非 wasm 构建放行 dead_code。
56#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
57#[derive(Clone, PartialEq)]
58enum UploadStatus {
59    Queued,
60    Uploading,
61    Done,
62    Failed(String),
63}
64
65/// 列表行数据(纯数据,两端都可编译;`web_sys::File` 句柄不进 signal,
66/// 由 WASM 端的 files 表以 id 关联保存,供重试取回)。
67// 同 UploadStatus:实例仅在 WASM 端构造。
68#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
69#[derive(Clone, PartialEq)]
70struct UploadItem {
71    id: u64,
72    name: String,
73    /// 入队时已用 `format_bytes` 格式化好的可读大小。
74    size: String,
75    status: UploadStatus,
76    /// 用户点了 ×:先播退出动画(animate-row-leave),EXIT_ANIM_MS 后真正摘除。
77    removing: bool,
78}
79
80/// 一批入队(一次拖拽/选择/粘贴)的完成追踪:remaining 归零且 ≥1 成功时回调一次。
81#[cfg(target_arch = "wasm32")]
82struct BatchCtx {
83    remaining: std::cell::Cell<usize>,
84    any_done: std::cell::Cell<bool>,
85}
86
87/// Worker 池共享状态:id 分配、重试句柄表、待传队列、在跑 worker 数。
88/// `use_hook` 持 `Rc<UploadPool>`:渲染期 clone 的是 Rc(共享同一实例),各入口闭包
89/// 与 worker 看到的永远是同一份队列/句柄表。
90#[cfg(target_arch = "wasm32")]
91struct UploadPool {
92    next_id: std::cell::Cell<u64>,
93    files: std::cell::RefCell<Vec<(u64, web_sys::File)>>,
94    queue: std::cell::RefCell<std::collections::VecDeque<(u64, std::rc::Rc<BatchCtx>)>>,
95    active_workers: std::cell::Cell<u32>,
96}
97
98/// 预校验:MIME 白名单 + 5MiB 上限。失败返回可读原因(直接展示在行内,不发请求)。
99#[cfg(any(test, target_arch = "wasm32"))]
100fn validate_file(mime: &str, size: u64) -> Result<(), String> {
101    if !ALLOWED_MIME.contains(&mime) {
102        return Err("不支持的文件类型(仅 JPEG / PNG / GIF / WebP)".into());
103    }
104    if size > MAX_UPLOAD_BYTES {
105        return Err("大小超过 5MB 限制".into());
106    }
107    Ok(())
108}
109
110/// 更新单条状态:write guard 在函数内立即释放,绝不跨 await 持有。
111#[cfg(target_arch = "wasm32")]
112fn set_status(items: &mut Signal<Vec<UploadItem>>, id: u64, status: UploadStatus) {
113    let mut guard = items.write();
114    if let Some(it) = guard.iter_mut().find(|it| it.id == id) {
115        it.status = status;
116    }
117}
118
119/// 单个 worker 的消费循环:队列空即退出并归还名额。
120///
121/// 张间停顿 `500ms × 当前并发数`:N 个 worker 并行时聚合速率恒 ≤ 2/s(停顿随 N
122/// 线性放大),与默认上传限流桶(`RATE_LIMIT_UPLOAD_PER_SEC=2` / `BURST=15`)对齐——
123/// N=1 时与旧顺序版逐张 500ms 完全一致,N 调大也不会触发 429。
124#[cfg(target_arch = "wasm32")]
125async fn worker_loop(
126    mut items: Signal<Vec<UploadItem>>,
127    pool: std::rc::Rc<UploadPool>,
128    concurrency: Signal<i32>,
129    on_uploaded: EventHandler<()>,
130) {
131    loop {
132        // borrow 不出块,guard 不跨 await。
133        let next = pool.queue.borrow_mut().pop_front();
134        let Some((id, batch)) = next else { break };
135        // 句柄可能已被「×」移除:跳过上传但仍计入批次完成度(否则该批永不合拢,
136        // 其他成功项的 on_uploaded 无法触发)。
137        let file = pool
138            .files
139            .borrow()
140            .iter()
141            .find(|(fid, _)| *fid == id)
142            .map(|(_, f)| f.clone());
143        if let Some(file) = file {
144            set_status(&mut items, id, UploadStatus::Uploading);
145            match upload_image_file(file).await {
146                Ok(_) => {
147                    set_status(&mut items, id, UploadStatus::Done);
148                    batch.any_done.set(true);
149                }
150                Err(msg) => set_status(&mut items, id, UploadStatus::Failed(msg)),
151            }
152        }
153        // 本批最后一条收尾且 ≥1 成功 → 回调一次(父组件刷新网格)。
154        let remaining = batch.remaining.get() - 1;
155        batch.remaining.set(remaining);
156        if remaining == 0 && batch.any_done.get() {
157            on_uploaded.call(());
158        }
159        // 队列未空则停顿压速率;停顿随并发数线性放大(见 fn doc),live 读取
160        // 让面板改动即时生效。clamp(1, 32) 纯防御:服务端已钳到 1–8,
161        // 此处只防异常值导致 500*n 溢出或路径级长停。
162        if !pool.queue.borrow().is_empty() {
163            let n = (*concurrency.peek()).clamp(1, 32) as u32;
164            crate::utils::time::sleep_ms(500 * n).await;
165        }
166    }
167    pool.active_workers.set(pool.active_workers.get() - 1);
168}
169
170/// 三入口收敛点:校验入队 + 按需补足 worker。仅 WASM 端存在(`web_sys::File` /
171/// `spawn` / `upload_image_file` 都是 WASM-only 符号)。
172#[cfg(target_arch = "wasm32")]
173fn enqueue_files(
174    mut items: Signal<Vec<UploadItem>>,
175    pool: std::rc::Rc<UploadPool>,
176    concurrency: Signal<i32>,
177    on_uploaded: EventHandler<()>,
178    new_files: Vec<web_sys::File>,
179) {
180    // 1) 校验入队:不合格直接记 Failed(不发请求);合格记 Queued 并留存句柄供重试。
181    let mut valid_ids = Vec::new();
182    for file in new_files {
183        let id = pool.next_id.get() + 1;
184        pool.next_id.set(id);
185        let item = UploadItem {
186            id,
187            name: file.name(),
188            size: format_bytes(file.size() as i64),
189            removing: false,
190            status: match validate_file(&file.type_(), file.size() as u64) {
191                Ok(()) => {
192                    pool.files.borrow_mut().push((id, file));
193                    valid_ids.push(id);
194                    UploadStatus::Queued
195                }
196                Err(msg) => UploadStatus::Failed(msg),
197            },
198        };
199        items.write().push(item);
200    }
201    if valid_ids.is_empty() {
202        return;
203    }
204
205    // 2) 一批一个 BatchCtx 追踪完成度;id 入共享队列后补足 worker:在跑数低于
206    //    并发上限且队列非空时逐个 spawn(worker 队列空自退,不会超额驻留)。
207    let batch = std::rc::Rc::new(BatchCtx {
208        remaining: std::cell::Cell::new(valid_ids.len()),
209        any_done: std::cell::Cell::new(false),
210    });
211    {
212        let mut q = pool.queue.borrow_mut();
213        for id in valid_ids {
214            q.push_back((id, batch.clone()));
215        }
216    }
217    // clamp 上界与 worker_loop 的停顿同款防御(正常值 1–8)。
218    let target = (*concurrency.peek()).clamp(1, 32) as u32;
219    while pool.active_workers.get() < target && !pool.queue.borrow().is_empty() {
220        pool.active_workers.set(pool.active_workers.get() + 1);
221        spawn(worker_loop(items, pool.clone(), concurrency, on_uploaded));
222    }
223}
224
225/// 素材上传 modal。
226///
227/// - `visible`:显隐控制(父组件持有;× / Esc / 点遮罩都会置 false)。
228/// - `on_uploaded`:一批上传完成且 ≥1 个成功时回调一次(父组件刷新网格)。
229#[component]
230#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
231pub fn AssetUploadModal(mut visible: Signal<bool>, on_uploaded: EventHandler<()>) -> Element {
232    let mut items: Signal<Vec<UploadItem>> = use_signal(Vec::new);
233    let mut drag_active = use_signal(|| false);
234    // 关闭动画状态:visible 翻 false 时先置 closing 播退出动画,EXIT_ANIM_MS 后再卸载内容。
235    let mut closing = use_signal(|| false);
236    // 是否曾开过弹窗:mount 时 visible=false 也会跑一次 use_effect,不加此守卫会
237    // 在页面加载后 200ms 内渲染一层透明遮罩(opacity:0 仍拦截点击)吞掉首次点击。
238    let mut opened = use_signal(|| false);
239    // worker 池共享状态(WASM-only):use_hook 持 Rc,跨渲染共享同一实例。
240    #[cfg(target_arch = "wasm32")]
241    let pool = use_hook(|| {
242        std::rc::Rc::new(UploadPool {
243            next_id: std::cell::Cell::new(0_u64),
244            files: std::cell::RefCell::new(Vec::new()),
245            queue: std::cell::RefCell::new(std::collections::VecDeque::new()),
246            active_workers: std::cell::Cell::new(0),
247        })
248    });
249    // 并发配置:默认值先行,挂载后经 admin server fn 拉服务端值覆盖(面板改了
250    // 无需刷新页面——下次打开本站页面即生效;进行中的 worker live 读取停顿)。
251    let mut concurrency: Signal<i32> =
252        use_signal(|| crate::models::settings::DEFAULT_UPLOAD_CONCURRENCY);
253
254    // 挂载即读服务端并发配置;失败静默回退默认值,不阻塞上传。
255    #[cfg(target_arch = "wasm32")]
256    {
257        use_effect(move || {
258            spawn(async move {
259                if let Ok(s) = crate::api::settings::get_upload_settings().await {
260                    concurrency.set(s.concurrency);
261                }
262            });
263        });
264    }
265
266    // window 全局监听(Esc 关闭 + 粘贴上传):组件始终挂载,故只在 mount 注册一次,
267    // use_drop 移除;handler 内 peek 守卫——modal 关闭后 Esc/粘贴都不响应。
268    // 骨架照抄 ui.rs Popover(use_hook 持 Closure + use_effect 注册 + use_drop 移除)。
269    #[cfg(target_arch = "wasm32")]
270    {
271        use std::cell::RefCell;
272        use std::rc::Rc;
273        type KeyClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>;
274        type PasteClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::ClipboardEvent)>;
275        type Listeners = Rc<RefCell<Option<(KeyClosure, PasteClosure)>>>;
276        let listeners: Listeners = use_hook(|| Rc::new(RefCell::new(None)));
277        let listeners_for_drop = listeners.clone();
278        // 组件 scope id(渲染期捕获):原生 window 监听器触发时 Dioxus 的 scope 栈
279        // 为空,`spawn` 会因 current_scope_id unwrap 空栈而 panic(与 sql_console
280        // 的 Ctrl+Enter 同源),监听器里用 Runtime::in_scope 重建 scope 上下文。
281        let scope_id = dioxus::core::Runtime::current()
282            .try_current_scope_id()
283            .unwrap_or(dioxus::core::ScopeId::ROOT);
284        // effect 闭包 move 捕获的是这份克隆,原值留给下方 drop/change 事件闭包。
285        let pool_for_paste = pool.clone();
286        use_effect(move || {
287            let Some(window) = web_sys::window() else {
288                return;
289            };
290            let on_keydown =
291                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
292                    if !*visible.peek() {
293                        return;
294                    }
295                    if ev.key() == "Escape" {
296                        closing.set(true);
297                        visible.set(false);
298                    }
299                })
300                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
301            // 内层 paste 闭包再持一份克隆(FnMut effect 的捕获不可被 move 出)。
302            let pool_in_paste = pool_for_paste.clone();
303            let on_paste =
304                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::ClipboardEvent| {
305                    if !*visible.peek() {
306                        return;
307                    }
308                    let Some(dt) = ev.clipboard_data() else {
309                        return;
310                    };
311                    let Some(list) = dt.files() else {
312                        return;
313                    };
314                    // 文本粘贴(无文件)不拦截:不 prevent_default,搜索框正常粘贴。
315                    if list.length() == 0 {
316                        return;
317                    }
318                    ev.prevent_default();
319                    let collected: Vec<web_sys::File> =
320                        (0..list.length()).filter_map(|i| list.item(i)).collect();
321                    if !collected.is_empty() {
322                        // 原生监听里无 scope 上下文:进入组件 scope 使 enqueue 内的
323                        // spawn 与 Dioxus 事件入口(拖拽/选择)行为完全一致。
324                        // Rc 句柄逐事件克隆进内层闭包(外层 FnMut 的捕获不可 move 出,
325                        // 同 sql_console 的 on_run_shortcut 闭包包装考虑)。
326                        let pool_call = pool_in_paste.clone();
327                        dioxus::core::Runtime::current().in_scope(scope_id, move || {
328                            enqueue_files(items, pool_call, concurrency, on_uploaded, collected);
329                        });
330                    }
331                })
332                    as Box<dyn FnMut(web_sys::ClipboardEvent)>);
333            use wasm_bindgen::JsCast;
334            let _ = window
335                .add_event_listener_with_callback("keydown", on_keydown.as_ref().unchecked_ref());
336            let _ =
337                window.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref());
338            *listeners.borrow_mut() = Some((on_keydown, on_paste));
339        });
340        use_drop(move || {
341            if let Some((on_keydown, on_paste)) = listeners_for_drop.borrow_mut().take() {
342                if let Some(window) = web_sys::window() {
343                    use wasm_bindgen::JsCast;
344                    let _ = window.remove_event_listener_with_callback(
345                        "keydown",
346                        on_keydown.as_ref().unchecked_ref(),
347                    );
348                    let _ = window.remove_event_listener_with_callback(
349                        "paste",
350                        on_paste.as_ref().unchecked_ref(),
351                    );
352                }
353            }
354        });
355    }
356
357    // 每个 move 事件闭包独占一份 Rc 克隆(move 闭包按整个变量捕获)。
358    #[cfg(target_arch = "wasm32")]
359    let (pool_for_drop, pool_for_change) = (pool.clone(), pool.clone());
360    // 行列表内层 for 闭包用:状态区 keyed 包装引入的嵌套闭包无法 move 外层闭包捕获的
361    // Rc(FnMut 链,E0507),故在组件体预克隆一份,供内层闭包从组件体直接捕获。
362    #[cfg(target_arch = "wasm32")]
363    let pool_for_rows = pool.clone();
364
365    // visible 翻转驱动关闭动画:关闭入口(× / 遮罩 / Esc)会同步先置 closing 再翻
366    // visible(同帧渲染在存活元素上换 .is-closing 类 → transition 从可见态出发);
367    // 这里只负责重开复位与 EXIT_ANIM_MS 后的复位卸载。闭包只订阅 visible,
368    // closing/opened 全用 peek 防自触发循环。
369    use_effect(move || {
370        if visible() {
371            opened.set(true);
372            closing.set(false);
373        } else if *opened.peek() {
374            // 非交互路径的 visible 翻转(理论上不存在)兜底补置 closing。
375            if !*closing.peek() {
376                closing.set(true);
377            }
378            spawn(async move {
379                crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
380                closing.set(false);
381            });
382        }
383    });
384
385    // closing() 的订阅读是必需的——closing.set 靠这个订阅触发重渲染,peek 会不重绘。
386    let is_closing = closing();
387    if !visible() && !is_closing {
388        return rsx! {};
389    }
390
391    // 快照当前列表(小 Vec,clone 成本可忽略;status 变化驱动重渲染)。
392    let items_snapshot = items.read().clone();
393
394    rsx! {
395        // 遮罩:点击关闭(照抄 AssetPickerModal)。
396        div {
397            class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6 modal-overlay animate-modal-overlay-enter",
398            class: if is_closing { "is-closing" } else { "" },
399            onclick: move |_| {
400                closing.set(true);
401                visible.set(false);
402            },
403            // 面板:阻止点击穿透到遮罩。
404            div {
405                class: "w-full max-w-lg max-h-[80vh] flex flex-col rounded-[2rem] bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)] shadow-xl overflow-hidden modal-panel animate-modal-panel-enter",
406                onclick: move |evt| evt.stop_propagation(),
407
408                // 头部(照抄 AssetPickerModal 头部类)。
409                div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
410                    h2 { class: "text-lg font-bold text-[var(--color-paper-primary)]",
411                        "上传素材"
412                    }
413                    div { class: "flex-1" }
414                    button {
415                        class: "shrink-0 w-8 h-8 flex items-center justify-center rounded-full text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
416                        aria_label: "关闭",
417                        onclick: move |_| {
418                            closing.set(true);
419                            visible.set(false);
420                        },
421                        "×"
422                    }
423                }
424
425                // 主体:拖放区 + 逐文件状态列表。
426                div { class: "flex-1 overflow-y-auto p-6 flex flex-col gap-4",
427                    // 拖放区 = label:点击天然触发隐藏 file input(同 CoverUploader),无需 JS。
428                    // 内部内容包一层 pointer-events-none,防子元素进出触发 dragleave 高亮闪烁。
429                    label {
430                        class: "flex flex-col items-center justify-center gap-2 px-6 py-10 border border-dashed rounded-2xl cursor-pointer transition-colors",
431                        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)]" },
432                        // ondragover 必须 prevent_default,否则浏览器直接打开文件。
433                        ondragover: move |evt| {
434                            evt.prevent_default();
435                            drag_active.set(true);
436                        },
437                        ondragenter: move |evt| {
438                            evt.prevent_default();
439                        },
440                        ondragleave: move |_| {
441                            drag_active.set(false);
442                        },
443                        ondrop: move |evt| {
444                            evt.prevent_default();
445                            drag_active.set(false);
446                            #[cfg(target_arch = "wasm32")]
447                            {
448                                let collected: Vec<web_sys::File> = evt
449                                    .files()
450                                    .into_iter()
451                                    .filter_map(|f| f.get_web_file())
452                                    .collect();
453                                if !collected.is_empty() {
454                                    enqueue_files(
455                                        items,
456                                        pool_for_drop.clone(),
457                                        concurrency,
458                                        on_uploaded,
459                                        collected,
460                                    );
461                                }
462                            }
463                        },
464                        div { class: "pointer-events-none flex flex-col items-center gap-2",
465                            // 上传图标(Feather 风格线框,照抄 CoverUploader)。
466                            svg {
467                                class: "w-8 h-8 text-[var(--color-paper-tertiary)]",
468                                xmlns: "http://www.w3.org/2000/svg",
469                                view_box: "0 0 24 24",
470                                fill: "none",
471                                stroke: "currentColor",
472                                stroke_width: "1.8",
473                                stroke_linecap: "round",
474                                stroke_linejoin: "round",
475                                path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
476                                polyline { points: "17 8 12 3 7 8" }
477                                line {
478                                    x1: "12",
479                                    y1: "3",
480                                    x2: "12",
481                                    y2: "15",
482                                }
483                            }
484                            p { class: "text-sm font-medium text-[var(--color-paper-primary)]",
485                                "拖拽图片到这里,或点击选择"
486                            }
487                            p { class: "text-xs text-[var(--color-paper-tertiary)]",
488                                "支持 Ctrl/⌘+V 粘贴 · JPEG / PNG / GIF / WebP · 单张 ≤ 5MB"
489                            }
490                        }
491                        input {
492                            r#type: "file",
493                            accept: "image/jpeg,image/png,image/gif,image/webp",
494                            multiple: true,
495                            class: "hidden",
496                            onchange: move |evt| {
497                                #[cfg(target_arch = "wasm32")]
498                                {
499                                    let collected: Vec<web_sys::File> = evt
500                                        .files()
501                                        .into_iter()
502                                        .filter_map(|f| f.get_web_file())
503                                        .collect();
504                                    if !collected.is_empty() {
505                                        enqueue_files(
506                                            items,
507                                            pool_for_change.clone(),
508                                            concurrency,
509                                            on_uploaded,
510                                            collected,
511                                        );
512                                    }
513                                }
514                            },
515                        }
516                    }
517
518                    // 逐文件状态列表。
519                    if !items_snapshot.is_empty() {
520                        div { class: "flex flex-col gap-2",
521                            for (idx, item) in items_snapshot.iter().enumerate() {
522                                {
523                                    let item_id = item.id;
524                                    // 状态切换 key(q/u/d/f):status 变化强制重挂载状态区 → 重放 status-pop。
525                                    let status_key = match &item.status {
526                                        UploadStatus::Queued => "q",
527                                        UploadStatus::Uploading => "u",
528                                        UploadStatus::Done => "d",
529                                        UploadStatus::Failed(_) => "f",
530                                    };
531                                    rsx! {
532                                        div {
533                                            key: "{item_id}",
534                                            class: "flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] px-4 py-3",
535                                            class: if item.removing { "animate-row-leave" } else { "animate-row-enter" },
536                                            // 进场阶梯 delay(复用 .animate-row-enter);removing 时清空,避免退出动画被残留 delay 推迟。
537                                            style: if item.removing { String::new() } else { format!("animation-delay:{}ms", idx * 30) },
538                                            // 左:文件名 + 大小(min-w-0 + truncate 防长文件名撑破行)。
539                                            div { class: "flex-1 min-w-0",
540                                                p {
541                                                    class: "text-sm truncate text-[var(--color-paper-primary)]",
542                                                    title: "{item.name}",
543                                                    "{item.name}"
544                                                }
545                                                p { class: "text-xs font-mono text-[var(--color-paper-tertiary)]", "{item.size}" }
546                                            }
547                                            // 右:状态 + 操作。外层 keyed(status 变化强制重挂载)→ 重放 status-pop。
548                                            for _ in std::iter::once(()) {
549                                                {
550                                                    // for 闭包体内声明 Rc 局部:下层 onclick 的 move 捕获落在本层
551                                                    // 局部(可自由 move),而非跨层 move 外层闭包捕获(FnMut 链 E0507)。
552                                                    #[cfg(target_arch = "wasm32")]
553                                                    let (pool_for_retry, pool_for_remove) =
554                                                        (pool_for_rows.clone(), pool_for_rows.clone());
555                                                    rsx! {
556                                                        div {
557                                                            key: "{status_key}",
558                                                            class: "flex items-center gap-2 shrink-0 animate-status-pop",
559                                                            match &item.status {
560                                                                UploadStatus::Queued => rsx! {
561                                                                    span { class: "text-xs text-[var(--color-paper-tertiary)] shrink-0", "等待中" }
562                                                                }, // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。 // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。
563                                                                UploadStatus::Uploading => rsx! {
564                                                                    span { class: "flex items-center gap-1.5 text-xs text-[var(--color-paper-secondary)] shrink-0",
565                                                                        span {
566                                                                            class: "inline-block w-3.5 h-3.5",
567                                                                            dangerous_inner_html: SPINNER_SVG,
568                                                                        }
569                                                                        "上传中"
570                                                                    }
571                                                                },
572                                                                UploadStatus::Done => rsx! {
573                                                                    span { class: "text-xs text-emerald-600 dark:text-emerald-400 shrink-0", "✓ 已上传" }
574                                                                },
575                                                                UploadStatus::Failed(msg) => rsx! {
576                                                                    // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。
577                                                                    span { class: "text-xs text-red-500 shrink-0 max-w-56 truncate", title: "{msg}", "{msg}" }
578                                                                    button {
579                                                                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] shrink-0",
580                                                                        onclick: move |_| {
581                                                                            #[cfg(target_arch = "wasm32")] // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会 // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会
582                                                                            {
583                                                                                // 从 files 表取回句柄重发本条。
584                                                                                let file = pool_for_retry
585                                                                                    .files
586                                                                                    .borrow()
587                                                                                    .iter()
588                                                                                    .find(|(fid, _)| *fid == item_id)
589                                                                                    .map(|(_, f)| f.clone());
590                                                                                if let Some(file) = file {
591                                                                                    set_status(&mut items, item_id, UploadStatus::Uploading);
592                                                                                    spawn(async move {
593                                                                                        match upload_image_file(file).await {
594                                                                                            Ok(_) => {
595                                                                                                set_status(&mut items, item_id, UploadStatus::Done);
596                                                                                                on_uploaded.call(());
597                                                                                            }
598                                                                                            Err(msg) => {
599                                                                                                set_status(&mut items, item_id, UploadStatus::Failed(msg));
600                                                                                            }
601                                                                                        }
602                                                                                    });
603                                                                                }
604                                                                            }
605                                                                        },
606                                                                        "重试"
607                                                                    }
608                                                                    button {
609                                                                        class: "text-xs cursor-pointer text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
610                                                                        aria_label: "移除",
611                                                                        onclick: move |_| {
612                                                                            // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会
613                                                                            // 跳过该条(不会在淡出行上写状态);EXIT_ANIM_MS 后真正摘除列表项。
614                                                                            {
615                                                                                let mut guard = items.write();
616                                                                                if let Some(it) = guard.iter_mut().find(|it| it.id == item_id) {
617                                                                                    it.removing = true;
618                                                                                }
619                                                                            }
620                                                                            #[cfg(target_arch = "wasm32")]
621                                                                            pool_for_remove
622                                                                                .files
623                                                                                .borrow_mut()
624                                                                                .retain(|(fid, _)| *fid != item_id);
625                                                                            spawn(async move {
626                                                                                crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
627                                                                                items.write().retain(|it| it.id != item_id);
628                                                                            });
629                                                                        },
630                                                                        "×"
631                                                                    }
632                                                                },
633                                                            }
634                                                        }
635                                                    }
636                                                }
637                                            }
638                                        }
639                                    }
640                                }
641                            }
642                        }
643                    }
644                }
645            }
646        }
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::{validate_file, ALLOWED_MIME, MAX_UPLOAD_BYTES};
653
654    /// 四种支持的类型在上限边缘全部接受。
655    #[test]
656    fn validate_file_accepts_supported_types_at_limit() {
657        for mime in ALLOWED_MIME {
658            assert!(
659                validate_file(mime, MAX_UPLOAD_BYTES).is_ok(),
660                "{mime} 应被接受"
661            );
662        }
663    }
664
665    /// svg 不在白名单(服务端同样拒绝)。
666    #[test]
667    fn validate_file_rejects_svg() {
668        assert!(validate_file("image/svg+xml", 1024).is_err());
669    }
670
671    /// 空 MIME(浏览器给不出类型)也拒绝。
672    #[test]
673    fn validate_file_rejects_empty_mime() {
674        assert!(validate_file("", 1024).is_err());
675    }
676
677    /// 上限 +1 字节拒绝。
678    #[test]
679    fn validate_file_rejects_oversize() {
680        assert!(validate_file("image/png", MAX_UPLOAD_BYTES + 1).is_err());
681    }
682}