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}