yggdrasil/components/assets/
upload_pool.rs1#[cfg(target_arch = "wasm32")]
20use leptos::prelude::*;
21
22#[cfg(target_arch = "wasm32")]
23use crate::bridges::tiptap::upload_image_file;
24#[cfg(target_arch = "wasm32")]
25use crate::utils::format_bytes;
26
27#[cfg(any(test, target_arch = "wasm32"))]
29const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024;
30#[cfg(any(test, target_arch = "wasm32"))]
32const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
33
34#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
37#[derive(Clone, PartialEq)]
38pub enum UploadStatus {
39 Queued,
40 Uploading,
41 Done,
42 Failed(String),
43}
44
45#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
49#[derive(Clone, PartialEq)]
50pub struct UploadItem {
51 pub(crate) id: u64,
52 pub(crate) name: String,
53 pub(crate) size: String,
55 pub(crate) status: UploadStatus,
56 pub(crate) removing: bool,
58}
59
60#[cfg(target_arch = "wasm32")]
62struct BatchCtx {
63 remaining: std::cell::Cell<usize>,
64 any_done: std::cell::Cell<bool>,
65}
66
67#[cfg(target_arch = "wasm32")]
72pub(crate) struct UploadPool {
73 next_id: std::cell::Cell<u64>,
74 files: std::cell::RefCell<Vec<(u64, web_sys::File)>>,
75 queue: std::cell::RefCell<std::collections::VecDeque<(u64, std::rc::Rc<BatchCtx>)>>,
76 active_workers: std::cell::Cell<u32>,
77 closed: std::cell::Cell<bool>,
79}
80
81#[cfg(target_arch = "wasm32")]
82impl UploadPool {
83 pub(crate) fn new() -> Self {
84 Self {
85 next_id: std::cell::Cell::new(0),
86 files: std::cell::RefCell::new(Vec::new()),
87 queue: std::cell::RefCell::new(std::collections::VecDeque::new()),
88 active_workers: std::cell::Cell::new(0),
89 closed: std::cell::Cell::new(false),
90 }
91 }
92
93 pub(crate) fn dispose(&self) {
95 self.closed.set(true);
96 self.files.borrow_mut().clear();
97 self.queue.borrow_mut().clear();
98 }
99
100 pub(crate) fn find_file(&self, id: u64) -> Option<web_sys::File> {
102 self.files
103 .borrow()
104 .iter()
105 .find(|(fid, _)| *fid == id)
106 .map(|(_, f)| f.clone())
107 }
108
109 pub(crate) fn remove_file(&self, id: u64) {
111 self.files.borrow_mut().retain(|(fid, _)| *fid != id);
112 }
113}
114
115#[cfg(any(test, target_arch = "wasm32"))]
120pub(crate) fn validate_file(mime: &str, size: u64) -> Result<(), String> {
121 if !ALLOWED_MIME.contains(&mime) {
122 return Err("不支持的文件类型(仅 JPEG / PNG / GIF / WebP)".into());
123 }
124 if size > MAX_UPLOAD_BYTES {
125 return Err("大小超过 5MB 限制".into());
126 }
127 Ok(())
128}
129
130#[cfg(target_arch = "wasm32")]
134pub(crate) fn set_status(items: RwSignal<Vec<UploadItem>>, id: u64, status: UploadStatus) -> bool {
135 items
136 .try_update(|list| {
137 if let Some(it) = list.iter_mut().find(|it| it.id == id) {
138 it.status = status;
139 }
140 })
141 .is_some()
142}
143
144#[cfg(target_arch = "wasm32")]
150async fn worker_loop(
151 items: RwSignal<Vec<UploadItem>>,
152 pool: std::rc::Rc<UploadPool>,
153 concurrency: RwSignal<i32>,
154 on_uploaded: Callback<()>,
155) {
156 loop {
158 if pool.closed.get() {
159 break;
160 }
161 let next = pool.queue.borrow_mut().pop_front();
163 let Some((id, batch)) = next else { break };
164 let file = pool.find_file(id);
167 if let Some(file) = file {
168 if !set_status(items, id, UploadStatus::Uploading) {
169 break;
170 }
171 let alive = match upload_image_file(file).await {
172 Ok(_) => {
173 batch.any_done.set(true);
174 set_status(items, id, UploadStatus::Done)
175 }
176 Err(msg) => set_status(items, id, UploadStatus::Failed(msg)),
177 };
178 if !alive || pool.closed.get() {
179 break;
180 }
181 }
182 let remaining = batch.remaining.get() - 1;
184 batch.remaining.set(remaining);
185 if remaining == 0 && batch.any_done.get() {
186 on_uploaded.run(());
187 }
188 if !pool.queue.borrow().is_empty() {
192 let Some(n) = concurrency.try_get_untracked() else {
193 break;
194 };
195 crate::utils::time::sleep_ms(500 * n.clamp(1, 32) as u32).await;
196 }
197 }
198 pool.active_workers
199 .set(pool.active_workers.get().saturating_sub(1));
200}
201
202#[cfg(target_arch = "wasm32")]
205pub(crate) fn enqueue_files(
206 items: RwSignal<Vec<UploadItem>>,
207 pool: std::rc::Rc<UploadPool>,
208 concurrency: RwSignal<i32>,
209 on_uploaded: Callback<()>,
210 new_files: Vec<web_sys::File>,
211) {
212 if pool.closed.get() {
213 return;
214 }
215 let mut valid_ids = Vec::new();
217 for file in new_files {
218 let id = pool.next_id.get() + 1;
219 pool.next_id.set(id);
220 let item = UploadItem {
221 id,
222 name: file.name(),
223 size: format_bytes(file.size() as i64),
224 removing: false,
225 status: match validate_file(&file.type_(), file.size() as u64) {
226 Ok(()) => {
227 pool.files.borrow_mut().push((id, file));
228 valid_ids.push(id);
229 UploadStatus::Queued
230 }
231 Err(msg) => UploadStatus::Failed(msg),
232 },
233 };
234 items.update(|list| list.push(item));
235 }
236 if valid_ids.is_empty() {
237 return;
238 }
239
240 let batch = std::rc::Rc::new(BatchCtx {
243 remaining: std::cell::Cell::new(valid_ids.len()),
244 any_done: std::cell::Cell::new(false),
245 });
246 {
247 let mut q = pool.queue.borrow_mut();
248 for id in valid_ids {
249 q.push_back((id, batch.clone()));
250 }
251 }
252 let target = concurrency.get_untracked().clamp(1, 32) as u32;
254 while pool.active_workers.get() < target && !pool.queue.borrow().is_empty() {
255 pool.active_workers.set(pool.active_workers.get() + 1);
256 leptos::task::spawn_local(worker_loop(items, pool.clone(), concurrency, on_uploaded));
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{validate_file, ALLOWED_MIME, MAX_UPLOAD_BYTES};
263
264 #[test]
266 fn validate_file_accepts_supported_types_at_limit() {
267 for mime in ALLOWED_MIME {
268 assert!(
269 validate_file(mime, MAX_UPLOAD_BYTES).is_ok(),
270 "{mime} 应被接受"
271 );
272 }
273 }
274
275 #[test]
277 fn validate_file_rejects_svg() {
278 assert!(validate_file("image/svg+xml", 1024).is_err());
279 }
280
281 #[test]
283 fn validate_file_rejects_empty_mime() {
284 assert!(validate_file("", 1024).is_err());
285 }
286
287 #[test]
289 fn validate_file_rejects_oversize() {
290 assert!(validate_file("image/png", MAX_UPLOAD_BYTES + 1).is_err());
291 }
292}