第十八章:文件上传与进度管理
博客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]是基础的文件选择方式,支持multiple和accept- 拖拽上传通过
ondragenter/ondragover/ondragleave/ondrop事件实现 - 进度条需要 XMLHttpRequest 的
onprogress事件,reqwest 不直接支持 - 图片预览使用
URL.createObjectURL创建临时 URL - 多文件并发上传需要管理每个文件的状态和进度
- 文件类型和大小验证在客户端做第一道检查,服务端仍需验证
dioxusuploadfileprogressdrag-drop