实现提供了完整的 Flutter Web 文件上传解决方案
Flutter Web 文件上传实现
以下是 Flutter Web 环境下文件上传的完整实现方案:
1. 基础文件上传实现
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_html/html.dart' as html;
import 'package:http/http.dart' as http;
import 'dart:convert';class WebFileUploader {// 打开文件选择器并上传文件static Future<void> pickAndUploadFile({required String uploadUrl,required Map<String, String> headers,String? fileFieldName,Map<String, String>? formData,Function(double progress)? onProgress,Function(String response)? onSuccess,Function(String error)? onError,List<String>? allowedFileTypes,}) async {try {// 创建隐藏的 file input 元素final input = html.FileUploadInputElement();input.accept = allowedFileTypes?.join(',') ?? '*/*';input.multiple = false;// 添加 change 事件监听器input.onChange.listen((event) async {final files = input.files;if (files != null && files.isNotEmpty) {final file = files[0];await _uploadFile(file: file,uploadUrl: uploadUrl,headers: headers,fileFieldName: fileFieldName,formData: formData,onProgress: onProgress,onSuccess: onSuccess,onError: onError,);}});// 触发文件选择对话框input.click();} catch (e) {onError?.call('文件选择失败: $e');}}// 多文件选择上传static Future<void> pickAndUploadMultipleFiles({required String uploadUrl,required Map<String, String> headers,String? fileFieldName,Map<String, String>? formData,Function(double progress, int current, int total)? onProgress,Function(List<String> responses)? onSuccess,Function(String error)? onError,List<String>? allowedFileTypes,int maxFiles = 5,}) async {try {final input = html.FileUploadInputElement();input.accept = allowedFileTypes?.join(',') ?? '*/*';input.multiple = true;input.onChange.listen((event) async {final files = input.files;if (files != null && files.isNotEmpty) {final selectedFiles = files.take(maxFiles).toList();final results = await _uploadMultipleFiles(files: selectedFiles,uploadUrl: uploadUrl,headers: headers,fileFieldName: fileFieldName,formData: formData,onProgress: onProgress,onError: onError,);onSuccess?.call(results);}});input.click();} catch (e) {onError?.call('文件选择失败: $e');}}// 单文件上传实现static Future<String> _uploadFile({required html.File file,required String uploadUrl,required Map<String, String> headers,String? fileFieldName,Map<String, String>? formData,Function(double progress)? onProgress,Function(String response)? onSuccess,Function(String error)? onError,}) async {try {final request = http.MultipartRequest('POST', Uri.parse(uploadUrl));// 添加 headersrequest.headers.addAll(headers);// 添加文件final fileField = fileFieldName ?? 'file';final stream = http.ByteStream(file.slice());final length = file.size;final multipartFile = http.MultipartFile(fileField,stream,length,filename: file.name,);request.files.add(multipartFile);// 添加表单数据if (formData != null) {request.fields.addAll(formData);}// 发送请求并监听进度final response = await request.send();// 监听上传进度double total = length.toDouble();double uploaded = 0;response.stream.listen((List<int> chunk) {uploaded += chunk.length;final progress = (uploaded / total) * 100;onProgress?.call(progress.clamp(0.0, 100.0));},onDone: () async {final responseStr = await response.stream.bytesToString();if (response.statusCode >= 200 && response.statusCode < 300) {onSuccess?.call(responseStr);} else {onError?.call('上传失败: ${response.statusCode} - $responseStr');}},onError: (error) {onError?.call('上传错误: $error');},);return await response.stream.bytesToString();} catch (e) {onError?.call('上传异常: $e');rethrow;}}// 多文件上传实现static Future<List<String>> _uploadMultipleFiles({required List<html.File> files,required String uploadUrl,required Map<String, String> headers,String? fileFieldName,Map<String, String>? formData,Function(double progress, int current, int total)? onProgress,Function(String error)? onError,}) async {final results = <String>[];for (int i = 0; i < files.length; i++) {try {final result = await _uploadFile(file: files[i],uploadUrl: uploadUrl,headers: headers,fileFieldName: fileFieldName,formData: formData,onProgress: (progress) {onProgress?.call(progress, i + 1, files.length);},);results.add(result);} catch (e) {onError?.call('文件 ${files[i].name} 上传失败: $e');results.add('{"error": "${files[i].name} 上传失败: $e"}');}}return results;}
}
2. 拖拽上传组件
import 'package:flutter/material.dart';class DragDropUploadArea extends StatefulWidget {final Function(List<html.File> files) onFilesDropped;final String title;final String subtitle;final Widget? icon;final List<String>? allowedFileTypes;final int maxFiles;const DragDropUploadArea({Key? key,required this.onFilesDropped,this.title = '拖拽文件到此处上传',this.subtitle = '支持单个或多个文件',this.icon,this.allowedFileTypes,this.maxFiles = 10,}) : super(key: key); _DragDropUploadAreaState createState() => _DragDropUploadAreaState();
}class _DragDropUploadAreaState extends State<DragDropUploadArea> {bool _isDragging = false;late html.DivElement _dropZone;void initState() {super.initState();_setupDropZone();}void _setupDropZone() {_dropZone = html.DivElement();_dropZone.style..position = 'absolute'..top = '0'..left = '0'..width = '100%'..height = '100%'..pointerEvents = 'none';// 防止默认拖放行为_dropZone.onDragOver.listen((event) {event.preventDefault();event.stopPropagation();setState(() => _isDragging = true);});_dropZone.onDragLeave.listen((event) {event.preventDefault();event.stopPropagation();setState(() => _isDragging = false);});_dropZone.onDrop.listen((event) {event.preventDefault();event.stopPropagation();setState(() => _isDragging = false);final files = event.dataTransfer?.files;if (files != null && files.isNotEmpty) {final selectedFiles = files.take(widget.maxFiles).toList();widget.onFilesDropped(selectedFiles);}});// 添加到 bodyhtml.document.body?.append(_dropZone);}void dispose() {_dropZone.remove();super.dispose();} Widget build(BuildContext context) {return Container(width: double.infinity,height: 200,decoration: BoxDecoration(border: Border.all(color: _isDragging ? Colors.blue : Colors.grey,width: _isDragging ? 3 : 2,),borderRadius: BorderRadius.circular(12),color: _isDragging ? Colors.blue.withOpacity(0.1) : Colors.grey.withOpacity(0.1),),child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [widget.icon ?? Icon(_isDragging ? Icons.cloud_done : Icons.cloud_upload,size: 48,color: _isDragging ? Colors.blue : Colors.grey,),const SizedBox(height: 16),Text(widget.title,style: TextStyle(fontSize: 18,fontWeight: FontWeight.bold,color: _isDragging ? Colors.blue : Colors.grey[700],),),const SizedBox(height: 8),Text(widget.subtitle,style: TextStyle(color: Colors.grey[600],),),],),);}
}
3. 完整的上传页面示例
import 'package:flutter/material.dart';
import 'package:universal_html/html.dart' as html;class FileUploadPage extends StatefulWidget { _FileUploadPageState createState() => _FileUploadPageState();
}class _FileUploadPageState extends State<FileUploadPage> {final List<UploadItem> _uploadItems = [];bool _isUploading = false; Widget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('文件上传'),backgroundColor: Colors.blue,),body: Padding(padding: const EdgeInsets.all(16.0),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [// 上传区域DragDropUploadArea(onFilesDropped: _handleFilesSelected,title: '拖拽文件到此处上传',subtitle: '支持图片、文档、压缩文件等',allowedFileTypes: ['.jpg', '.png', '.pdf', '.doc', '.docx', '.zip'],maxFiles: 5,),const SizedBox(height: 20),// 手动选择文件按钮Row(children: [ElevatedButton.icon(onPressed: _selectFiles,icon: Icon(Icons.attach_file),label: Text('选择文件'),),SizedBox(width: 10),ElevatedButton.icon(onPressed: _selectFiles,icon: Icon(Icons.photo_library),label: Text('选择图片'),),],),const SizedBox(height: 20),// 上传按钮if (_uploadItems.isNotEmpty) ...[Row(children: [ElevatedButton(onPressed: _isUploading ? null : _uploadAllFiles,style: ElevatedButton.styleFrom(backgroundColor: Colors.green,foregroundColor: Colors.white,),child: _isUploading? Row(mainAxisSize: MainAxisSize.min,children: [SizedBox(width: 16,height: 16,child: CircularProgressIndicator(strokeWidth: 2,valueColor: AlwaysStoppedAnimation(Colors.white),),),SizedBox(width: 8),Text('上传中...'),],): Text('开始上传 (${_uploadItems.length})'),),SizedBox(width: 10),TextButton(onPressed: _clearAll,child: Text('清空列表'),),],),SizedBox(height: 20),],// 上传列表Expanded(child: _uploadItems.isEmpty? Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(Icons.cloud_upload, size: 64, color: Colors.grey),SizedBox(height: 16),Text('暂无待上传文件',style: TextStyle(fontSize: 16, color: Colors.grey),),],),): ListView.builder(itemCount: _uploadItems.length,itemBuilder: (context, index) {return _buildUploadItem(_uploadItems[index]);},),),],),),);}Widget _buildUploadItem(UploadItem item) {return Card(margin: EdgeInsets.symmetric(vertical: 4),child: ListTile(leading: _getFileIcon(item.fileName),title: Text(item.fileName,overflow: TextOverflow.ellipsis,),subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [if (item.status == UploadStatus.uploading) ...[SizedBox(height: 4),LinearProgressIndicator(value: item.progress / 100,backgroundColor: Colors.grey[200],valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),),SizedBox(height: 4),],Text(_getStatusText(item),style: TextStyle(color: _getStatusColor(item.status),),),],),trailing: _getTrailingWidget(item),),);}Widget _getFileIcon(String fileName) {final ext = fileName.split('.').last.toLowerCase();final icon = switch (ext) {'jpg' || 'jpeg' || 'png' || 'gif' => Icons.image,'pdf' => Icons.picture_as_pdf,'doc' || 'docx' => Icons.description,'zip' || 'rar' => Icons.archive,_ => Icons.insert_drive_file,};return Icon(icon, color: Colors.blue);}Widget? _getTrailingWidget(UploadItem item) {return switch (item.status) {UploadStatus.pending => IconButton(icon: Icon(Icons.cancel, color: Colors.red),onPressed: () => _removeItem(item),),UploadStatus.uploading => SizedBox(width: 24,height: 24,child: CircularProgressIndicator(strokeWidth: 2,value: item.progress / 100,),),UploadStatus.completed => Icon(Icons.check_circle, color: Colors.green),UploadStatus.failed => Row(mainAxisSize: MainAxisSize.min,children: [Icon(Icons.error, color: Colors.red),IconButton(icon: Icon(Icons.refresh, color: Colors.blue),onPressed: () => _retryUpload(item),),],),};}String _getStatusText(UploadItem item) {return switch (item.status) {UploadStatus.pending => '等待上传',UploadStatus.uploading => '上传中: ${item.progress.toStringAsFixed(1)}%',UploadStatus.completed => '上传成功',UploadStatus.failed => '上传失败: ${item.error}',};}Color _getStatusColor(UploadStatus status) {return switch (status) {UploadStatus.pending => Colors.orange,UploadStatus.uploading => Colors.blue,UploadStatus.completed => Colors.green,UploadStatus.failed => Colors.red,};}void _selectFiles() {WebFileUploader.pickAndUploadMultipleFiles(uploadUrl: 'https://your-upload-endpoint.com/upload',headers: {'Authorization': 'Bearer your-token',},onProgress: (progress, current, total) {setState(() {if (_uploadItems.length >= current) {_uploadItems[current - 1] = _uploadItems[current - 1].copyWith(progress: progress,status: UploadStatus.uploading,);}});},onSuccess: (responses) {setState(() {for (int i = 0; i < responses.length; i++) {if (i < _uploadItems.length) {_uploadItems[i] = _uploadItems[i].copyWith(status: UploadStatus.completed,progress: 100,);}}_isUploading = false;});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${responses.length} 个文件上传成功'),backgroundColor: Colors.green,),);},onError: (error) {setState(() {for (var item in _uploadItems) {if (item.status == UploadStatus.uploading) {_uploadItems[_uploadItems.indexOf(item)] = item.copyWith(status: UploadStatus.failed,error: error,);}}_isUploading = false;});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('上传失败: $error'),backgroundColor: Colors.red,),);},allowedFileTypes: ['.jpg', '.png', '.pdf', '.doc', '.docx', '.zip'],maxFiles: 5,);}void _handleFilesSelected(List<html.File> files) {setState(() {_uploadItems.addAll(files.map((file) => UploadItem(fileName: file.name,fileSize: file.size,status: UploadStatus.pending,progress: 0,)).toList(),);});}void _uploadAllFiles() {if (_uploadItems.isEmpty) return;setState(() {_isUploading = true;for (int i = 0; i < _uploadItems.length; i++) {if (_uploadItems[i].status != UploadStatus.completed) {_uploadItems[i] = _uploadItems[i].copyWith(status: UploadStatus.uploading,progress: 0,);}}});_selectFiles(); // 这会触发实际的上传过程}void _removeItem(UploadItem item) {setState(() {_uploadItems.remove(item);});}void _retryUpload(UploadItem item) {setState(() {final index = _uploadItems.indexOf(item);_uploadItems[index] = item.copyWith(status: UploadStatus.pending,progress: 0,error: null,);});}void _clearAll() {setState(() {_uploadItems.clear();});}
}enum UploadStatus { pending, uploading, completed, failed }class UploadItem {final String fileName;final int fileSize;final UploadStatus status;final double progress;final String? error;UploadItem({required this.fileName,required this.fileSize,required this.status,required this.progress,this.error,});UploadItem copyWith({String? fileName,int? fileSize,UploadStatus? status,double? progress,String? error,}) {return UploadItem(fileName: fileName ?? this.fileName,fileSize: fileSize ?? this.fileSize,status: status ?? this.status,progress: progress ?? this.progress,error: error ?? this.error,);}
}
4. pubspec.yaml 依赖
dependencies:flutter:sdk: flutterhttp: ^0.13.5universal_html: ^2.2.0dev_dependencies:flutter_test:sdk: flutterflutter_lints: ^2.0.0
主要特性
-
多种上传方式:
- 点击按钮选择文件
- 拖拽上传
- 多文件选择
-
进度显示:
- 实时上传进度
- 文件状态跟踪
-
文件类型限制:
- 支持设置允许的文件类型
- 文件数量限制
-
错误处理:
- 网络错误处理
- 上传失败重试
- 用户友好的错误提示
-
用户体验:
- 拖拽可视化反馈
- 上传状态清晰显示
- 响应式界面设计
这个实现提供了完整的 Flutter Web 文件上传解决方案,可以根据实际需求进行调整和扩展。