yggdrasil/components/assets/
upload_pool.rs1#[cfg(target_arch = "wasm32")]
19use dioxus::prelude::*;
20
21#[cfg(target_arch = "wasm32")]
22use crate::bridges::tiptap::upload_image_file;
23#[cfg(target_arch = "wasm32")]
24use crate::utils::format_bytes;
25
26#[cfg(any(test, target_arch = "wasm32"))]
28const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
29#[cfg(any(test, target_arch = "wasm32"))]
31const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
32
33#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
36#[derive(Clone, PartialEq)]
37pub(crate) enum UploadStatus {
38 Queued,
39 Uploading,
40 Done,
41 Failed(String),
42}
43
44#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
48#[derive(Clone, PartialEq)]
49pub(crate) struct UploadItem {
50 pub(crate) id: u64,
51 pub(crate) name: String,
52 pub(crate) size: String,
54 pub(crate) status: UploadStatus,
55 pub(crate) removing: bool,
57}
58
59#[cfg(target_arch = "wasm32")]
61struct BatchCtx {
62 remaining: std::cell::Cell<usize>,
63 any_done: std::cell::Cell<bool>,
64}
65
66#[cfg(target_arch = "wasm32")]
71pub(crate) struct UploadPool {
72 next_id: std::cell::Cell<u64>,
73 files: std::cell::RefCell<Vec<(u64, web_sys::File)>>,
74 queue: std::cell::RefCell<std::collections::VecDeque<(u64, std::rc::Rc<BatchCtx>)>>,
75 active_workers: std::cell::Cell<u32>,
76}
77
78#[cfg(target_arch = "wasm32")]
79impl UploadPool {
80 pub(crate) fn new() -> Self {
81 Self {
82 next_id: std::cell::Cell::new(0),
83 files: std::cell::RefCell::new(Vec::new()),
84 queue: std::cell::RefCell::new(std::collections::VecDeque::new()),
85 active_workers: std::cell::Cell::new(0),
86 }
87 }
88
89 pub(crate) fn find_file(&self, id: u64) -> Option<web_sys::File> {
91 self.files
92 .borrow()
93 .iter()
94 .find(|(fid, _)| *fid == id)
95 .map(|(_, f)| f.clone())
96 }
97
98 pub(crate) fn remove_file(&self, id: u64) {
100 self.files.borrow_mut().retain(|(fid, _)| *fid != id);
101 }
102}
103
104#[cfg(any(test, target_arch = "wasm32"))]
109pub(crate) fn validate_file(mime: &str, size: u64) -> Result<(), String> {
110 if !ALLOWED_MIME.contains(&mime) {
111 return Err("不支持的文件类型(仅 JPEG / PNG / GIF / WebP)".into());
112 }
113 if size > MAX_UPLOAD_BYTES {
114 return Err("大小超过 5MB 限制".into());
115 }
116 Ok(())
117}
118
119#[cfg(target_arch = "wasm32")]
121pub(crate) fn set_status(items: &mut Signal<Vec<UploadItem>>, id: u64, status: UploadStatus) {
122 let mut guard = items.write();
123 if let Some(it) = guard.iter_mut().find(|it| it.id == id) {
124 it.status = status;
125 }
126}
127
128#[cfg(target_arch = "wasm32")]
134async fn worker_loop(
135 mut items: Signal<Vec<UploadItem>>,
136 pool: std::rc::Rc<UploadPool>,
137 concurrency: Signal<i32>,
138 on_uploaded: EventHandler<()>,
139) {
140 loop {
141 let next = pool.queue.borrow_mut().pop_front();
143 let Some((id, batch)) = next else { break };
144 let file = pool.find_file(id);
147 if let Some(file) = file {
148 set_status(&mut items, id, UploadStatus::Uploading);
149 match upload_image_file(file).await {
150 Ok(_) => {
151 set_status(&mut items, id, UploadStatus::Done);
152 batch.any_done.set(true);
153 }
154 Err(msg) => set_status(&mut items, id, UploadStatus::Failed(msg)),
155 }
156 }
157 let remaining = batch.remaining.get() - 1;
159 batch.remaining.set(remaining);
160 if remaining == 0 && batch.any_done.get() {
161 on_uploaded.call(());
162 }
163 if !pool.queue.borrow().is_empty() {
167 let n = (*concurrency.peek()).clamp(1, 32) as u32;
168 crate::utils::time::sleep_ms(500 * n).await;
169 }
170 }
171 pool.active_workers.set(pool.active_workers.get() - 1);
172}
173
174#[cfg(target_arch = "wasm32")]
177pub(crate) fn enqueue_files(
178 mut items: Signal<Vec<UploadItem>>,
179 pool: std::rc::Rc<UploadPool>,
180 concurrency: Signal<i32>,
181 on_uploaded: EventHandler<()>,
182 new_files: Vec<web_sys::File>,
183) {
184 let mut valid_ids = Vec::new();
186 for file in new_files {
187 let id = pool.next_id.get() + 1;
188 pool.next_id.set(id);
189 let item = UploadItem {
190 id,
191 name: file.name(),
192 size: format_bytes(file.size() as i64),
193 removing: false,
194 status: match validate_file(&file.type_(), file.size() as u64) {
195 Ok(()) => {
196 pool.files.borrow_mut().push((id, file));
197 valid_ids.push(id);
198 UploadStatus::Queued
199 }
200 Err(msg) => UploadStatus::Failed(msg),
201 },
202 };
203 items.write().push(item);
204 }
205 if valid_ids.is_empty() {
206 return;
207 }
208
209 let batch = std::rc::Rc::new(BatchCtx {
212 remaining: std::cell::Cell::new(valid_ids.len()),
213 any_done: std::cell::Cell::new(false),
214 });
215 {
216 let mut q = pool.queue.borrow_mut();
217 for id in valid_ids {
218 q.push_back((id, batch.clone()));
219 }
220 }
221 let target = (*concurrency.peek()).clamp(1, 32) as u32;
223 while pool.active_workers.get() < target && !pool.queue.borrow().is_empty() {
224 pool.active_workers.set(pool.active_workers.get() + 1);
225 spawn(worker_loop(items, pool.clone(), concurrency, on_uploaded));
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::{validate_file, ALLOWED_MIME, MAX_UPLOAD_BYTES};
232
233 #[test]
235 fn validate_file_accepts_supported_types_at_limit() {
236 for mime in ALLOWED_MIME {
237 assert!(
238 validate_file(mime, MAX_UPLOAD_BYTES).is_ok(),
239 "{mime} 应被接受"
240 );
241 }
242 }
243
244 #[test]
246 fn validate_file_rejects_svg() {
247 assert!(validate_file("image/svg+xml", 1024).is_err());
248 }
249
250 #[test]
252 fn validate_file_rejects_empty_mime() {
253 assert!(validate_file("", 1024).is_err());
254 }
255
256 #[test]
258 fn validate_file_rejects_oversize() {
259 assert!(validate_file("image/png", MAX_UPLOAD_BYTES + 1).is_err());
260 }
261}