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}