第十八章:文件上传与进度管理

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十八章:文件上传与进度管理

1. 基础文件上传

1.1 文件选择器

#[component]
fn FileUpload() -> Element {
    let files = use_signal(|| Vec::<FileData>::new());

    rsx! {
        div { class: "space-y-4",
            input {
                r#type: "file",
                multiple: true,
                accept: ".jpg,.png,.gif,.md",
                oninput: move |e| {
                    if let Some(file_list) = e.files() {
                        for file in file_list.files() {
                            let name = file.name();
                            let size = file.size();
                            files.write().push(FileData {
                                name,
                                size,
                                progress: 0,
                                status: "pending".to_string(),
                            });
                        }
                    }
                },
            }
            // 文件列表
            for file in files.read().iter() {
                div { class: "flex items-center gap-3 px-4 py-2 border rounded",
                    span { class: "flex-1 text-sm", "{file.name}" }
                    span { class: "text-xs text-gray-500",
                        format_size(file.size)
                    }
                }
            }
        }
    }
}

1.2 使用 FormData 上传

async fn upload_file(file: File) -> Result<String, String> {
    let form = reqwest::multipart::Form::new()
        .part("file", reqwest::multipart::Part::bytes(file.bytes())
            .file_name(file.name())
            .mime_str(&file.mime_type()).unwrap());

    let resp = reqwest::Client::new()
        .post("/api/upload")
        .multipart(form)
        .send()
        .await
        .map_err(|e| e.to_string())?;

    let body = resp.text().await.map_err(|e| e.to_string())?;
    Ok(body)
}

#[component]
fn UploadButton() -> Element {
    let mut uploading = use_signal(|| false);
    let mut result = use_signal(|| Option::<String>::None);

    rsx! {
        input {
            r#type: "file",
            disabled: uploading(),
            oninput: move |e| {
                if let Some(file_list) = e.files() {
                    if let Some(file) = file_list.files().into_iter().next() {
                        uploading.set(true);
                        spawn(async move {
                            match upload_file(file).await {
                                Ok(url) => result.set(Some(url)),
                                Err(e) => result.set(Some(format!("error: {e}"))),
                            }
                            uploading.set(false);
                        });
                    }
                }
            },
        }
        if uploading() { span { "上传中..." } }
        if let Some(ref r) = result() { span { "{r}" } }
    }
}

2. 带进度条的上传

2.1 XMLHttpRequest 进度事件

// reqwest 不支持进度回调,需要使用 web-sys 的 XMLHttpRequest
async fn upload_with_progress(
    file: File,
    on_progress: impl Fn(f64) + 'static,
) -> Result<String, String> {
    // 使用 web-sys 的 FormData 和 XMLHttpRequest
    let form = web_sys::FormData::new().unwrap();
    form.append_with_blob("file", &file.blob()).unwrap();

    let (tx, rx) = std::sync::mpsc::channel();

    let xhr = web_sys::XMLHttpRequest::new().unwrap();
    xhr.upload().set_onprogress(Some(&{
        let tx = tx.clone();
        Closure::wrap(Box::new(move |event: web_sys::ProgressEvent| {
            if event.total() > 0 {
                let pct = event.loaded() as f64 / event.total() as f64;
                let _ = tx.send(pct);
            }
        }) as Box<dyn Fn(_)>)
    }));
    // ... 发送请求
    todo!()
}

2.2 简化版进度 Hook

struct UploadTask {
    id: u64,
    name: String,
    progress: f64,       // 0.0 ~ 1.0
    status: UploadStatus,
}

enum UploadStatus {
    Pending,
    Uploading,
    Success(String),     // 返回的 URL
    Error(String),
}

fn use_uploader() -> (
    Signal<Vec<UploadTask>>,
    impl Fn(File),
) {
    let tasks = use_signal(|| Vec::<UploadTask>::new());
    let counter = use_signal(|| 0u64);

    let add_file = move |file: File| {
        let id = counter() + 1;
        counter.set(id);
        let name = file.name();

        tasks.write().push(UploadTask {
            id,
            name: name.clone(),
            progress: 0.0,
            status: UploadStatus::Uploading,
        });

        spawn(async move {
            // 模拟进度更新
            for i in 1..=10 {
                tokio::time::sleep(std::time::Duration::from_millis(200)).await;
                let mut t = tasks.write();
                if let Some(task) = t.iter_mut().find(|t| t.id == id) {
                    task.progress = i as f64 / 10.0;
                }
            }

            // 上传完成
            let mut t = tasks.write();
            if let Some(task) = t.iter_mut().find(|t| t.id == id) {
                task.status = UploadStatus::Success("/uploads/file".to_string());
            }
        });
    };

    (tasks, add_file)
}

#[component]
fn UploadArea() -> Element {
    let (tasks, add_file) = use_uploader();

    rsx! {
        div { class: "space-y-2",
            input {
                r#type: "file",
                oninput: move |e| {
                    if let Some(files) = e.files() {
                        for file in files.files() {
                            add_file(file);
                        }
                    }
                },
            }
            for task in tasks.read().iter() {
                div { class: "flex items-center gap-3 px-4 py-2 border rounded",
                    span { class: "flex-1 text-sm truncate", "{task.name}" }
                    // 进度条
                    div { class: "w-32 h-2 bg-gray-200 rounded-full overflow-hidden",
                        div {
                            class: "h-full bg-blue-500 rounded-full transition-all duration-300",
                            style: "width: {task.progress * 100.0}%;",
                        }
                    }
                    span { class: "text-xs w-12 text-right",
                        "{task.progress * 100.0:.0}%"
                    }
                    match task.status {
                        UploadStatus::Success(_) => rsx! { span { "✅" } },
                        UploadStatus::Error(_) => rsx! { span { "❌" } },
                        _ => rsx! { span { "⏳" } },
                    }
                }
            }
        }
    }
}

3. 拖拽上传

3.1 拖拽区域组件

#[component]
fn DropZone(on_files: EventHandler<Vec<File>>) -> Element {
    let mut dragging = use_signal(|| false);

    rsx! {
        div {
            class: "
                relative border-2 border-dashed rounded-xl p-12 text-center
                transition-all duration-200 cursor-pointer
            ",
            style: "
                border-color: {if dragging() { \"var(--primary)\" } else { \"var(--border)\" }};
                background: {if dragging() { \"var(--primary-light)\" } else { \"transparent\" }};
            ",
            // 拖拽事件
            ondragenter: move |e| {
                e.prevent_default();
                dragging.set(true);
            },
            ondragover: move |e| {
                e.prevent_default();
                dragging.set(true);
            },
            ondragleave: move |e| {
                e.prevent_default();
                dragging.set(false);
            },
            ondrop: move |e| {
                e.prevent_default();
                dragging.set(false);
                if let Some(files) = e.files() {
                    let file_vec: Vec<File> = files.files().collect();
                    on_files.call(file_vec);
                }
            },
            // 点击选择
            onclick: move |_| {
                // 触发隐藏的 file input
            },

            // 图标和提示
            div { class: "text-4xl mb-3", if dragging() { "📂" } else { "📁" } }
            p { class: "text-sm font-medium",
                if dragging() { "释放文件以上传" } else { "拖拽文件到此处,或点击选择" }
            }
            p { class: "text-xs mt-1", style: "color: var(--tertiary);",
                "支持 PNG、JPG、GIF,单个文件不超过 10MB"
            }
        }
    }
}

3.2 图片预览

#[component]
fn ImagePreview(file: File) -> Element {
    let url = use_signal(|| Option::<String>::None);

    // 创建对象 URL 用于预览
    use_effect(move || {
        let blob = file.blob();
        let obj_url = web_sys::Url::create_object_url_with_blob(&blob).ok();
        url.set(obj_url);

        // 清理
        spawn(async move {
            // 组件卸载时释放对象 URL
        });
    });

    rsx! {
        div { class: "relative group",
            if let Some(ref src) = url() {
                img {
                    class: "w-24 h-24 object-cover rounded-lg",
                    src: "{src}",
                    alt: "{file.name()}",
                }
            }
            // 删除按钮
            button {
                class: "absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white
                        rounded-full text-xs opacity-0 group-hover:opacity-100
                        transition-opacity",
                "×"
            }
            // 文件名
            span { class: "text-xs truncate block mt-1 w-24",
                "{file.name()}"
            }
        }
    }
}

4. 多文件并发上传

#[component]
fn MultiUploader() -> Element {
    let tasks = use_signal(|| Vec::<UploadTask>::new());
    let counter = use_signal(|| 0u64);

    let add_files = move |new_files: Vec<File>| {
        for file in new_files {
            let id = counter() + 1;
            counter.set(id);
            let name = file.name();
            let size = file.size();

            tasks.write().push(UploadTask {
                id,
                name,
                progress: 0.0,
                status: UploadStatus::Pending,
            });

            // 启动上传
            spawn(async move {
                // 设置状态为上传中
                let mut t = tasks.write();
                if let Some(task) = t.iter_mut().find(|t| t.id == id) {
                    task.status = UploadStatus::Uploading;
                }
                drop(t);

                // 执行上传(模拟)
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;

                let mut t = tasks.write();
                if let Some(task) = t.iter_mut().find(|t| t.id == id) {
                    task.progress = 1.0;
                    task.status = UploadStatus::Success(format!("/uploads/{id}"));
                }
            });
        }
    };

    let total_progress = use_memo(move || {
        let t = tasks.read();
        if t.is_empty() { return 1.0; }
        t.iter().map(|t| t.progress).sum::<f64>() / t.len() as f64
    });

    let success_count = use_memo(move || {
        tasks.read().iter()
            .filter(|t| matches!(t.status, UploadStatus::Success(_)))
            .count()
    });

    rsx! {
        div { class: "space-y-4",
            // 总体进度
            div { class: "flex items-center gap-3",
                div { class: "flex-1 h-3 bg-gray-200 rounded-full overflow-hidden",
                    div {
                        class: "h-full bg-green-500 rounded-full transition-all",
                        style: "width: {total_progress * 100.0}%;",
                    }
                }
                span { class: "text-sm",
                    "{success_count} / {tasks.read().len()}"
                }
            }

            // 拖拽区域
            DropZone { on_files: add_files }

            // 文件列表
            for task in tasks.read().iter() {
                UploadItem { task: task.clone() }
            }
        }
    }
}

5. 文件类型验证与大小限制

fn validate_file(file: &File) -> Result<(), String> {
    let max_size = 10 * 1024 * 1024; // 10MB
    let allowed_types = ["image/jpeg", "image/png", "image/gif", "text/markdown"];

    if file.size() > max_size {
        return Err(format!("文件 {} 超过 10MB 限制", file.name()));
    }

    if !allowed_types.contains(&file.mime_type().as_str()) {
        return Err(format!("文件类型 {} 不被支持", file.mime_type()));
    }

    Ok(())
}

#[component]
fn ValidatedUpload() -> Element {
    let errors = use_signal(|| Vec::<String>::new());

    let handle_files = move |files: Vec<File>| {
        errors.clear();
        for file in files {
            if let Err(e) = validate_file(&file) {
                errors.write().push(e);
            }
        }
    };

    rsx! {
        for err in errors.read().iter() {
            div { class: "text-red-500 text-sm", "{err}" }
        }
        DropZone { on_files: handle_files }
    }
}

6. 上传组件总结

// 完整的 use_upload Hook
fn use_upload(options: UploadOptions) -> UseUpload {
    // 管理文件列表、进度、状态
    // 支持拖拽、多文件、并发控制
    // 自动验证、错误处理
}

// 使用
#[component]
fn AvatarUpload() -> Element {
    let upload = use_upload(UploadOptions {
        max_files: 1,
        max_size: 5 * 1024 * 1024,
        accept: vec!["image/jpeg", "image/png"],
        endpoint: "/api/upload/avatar",
        on_complete: move |url| {
            println!("头像上传完成: {url}");
        },
    });

    rsx! {
        if let Some(url) = upload.preview() {
            img { src: "{url}", class: "w-20 h-20 rounded-full" }
        }
        DropZone { on_files: move |f| upload.upload(f) }
    }
}

7. 小结

  • input[type=file] 是基础的文件选择方式,支持 multipleaccept
  • 拖拽上传通过 ondragenter/ondragover/ondragleave/ondrop 事件实现
  • 进度条需要 XMLHttpRequest 的 onprogress 事件,reqwest 不直接支持
  • 图片预览使用 URL.createObjectURL 创建临时 URL
  • 多文件并发上传需要管理每个文件的状态和进度
  • 文件类型和大小验证在客户端做第一道检查,服务端仍需验证
dioxusuploadfileprogressdrag-drop