Flutter Web 文件上传实现
以下是 Flutter Web 环境下文件上传的完整实现方案:
1. 基础文件上传实现
dart
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));
// 添加 headers
request.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. 拖拽上传组件
dart
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);
@override
_DragDropUploadAreaState createState() => _DragDropUploadAreaState();
}
class _DragDropUploadAreaState extends State<DragDropUploadArea> {
bool _isDragging = false;
late html.DivElement _dropZone;
@override
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);
}
});
// 添加到 body
html.document.body?.append(_dropZone);
}
@override
void dispose() {
_dropZone.remove();
super.dispose();
}
@override
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. 完整的上传页面示例
dart
import 'package:flutter/material.dart';
import 'package:universal_html/html.dart' as html;
class FileUploadPage extends StatefulWidget {
@override
_FileUploadPageState createState() => _FileUploadPageState();
}
class _FileUploadPageState extends State<FileUploadPage> {
final List<UploadItem> _uploadItems = [];
bool _isUploading = false;
@override
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 依赖
yaml
dependencies:
flutter:
sdk: flutter
http: ^0.13.5
universal_html: ^2.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
主要特性
-
多种上传方式:
- 点击按钮选择文件
- 拖拽上传
- 多文件选择
-
进度显示:
- 实时上传进度
- 文件状态跟踪
-
文件类型限制:
- 支持设置允许的文件类型
- 文件数量限制
-
错误处理:
- 网络错误处理
- 上传失败重试
- 用户友好的错误提示
-
用户体验:
- 拖拽可视化反馈
- 上传状态清晰显示
- 响应式界面设计
这个实现提供了完整的 Flutter Web 文件上传解决方案,可以根据实际需求进行调整和扩展。