实现提供了完整的 Flutter Web 文件上传解决方案

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

主要特性

  1. 多种上传方式

    • 点击按钮选择文件
    • 拖拽上传
    • 多文件选择
  2. 进度显示

    • 实时上传进度
    • 文件状态跟踪
  3. 文件类型限制

    • 支持设置允许的文件类型
    • 文件数量限制
  4. 错误处理

    • 网络错误处理
    • 上传失败重试
    • 用户友好的错误提示
  5. 用户体验

    • 拖拽可视化反馈
    • 上传状态清晰显示
    • 响应式界面设计

这个实现提供了完整的 Flutter Web 文件上传解决方案,可以根据实际需求进行调整和扩展。

相关推荐
im_AMBER4 小时前
Web 开发 29
前端·学习·web
前端开发爱好者4 小时前
Vite➕ 收费了!
前端·javascript·vue.js
gplitems1234 小时前
Petslist – Pet listing WordPress Theme Free Download
linux·服务器·前端
dcloud_jibinbin4 小时前
【uniapp】体验优化:开源工具集 uni-toolkit 发布
前端·webpack·性能优化·小程序·uni-app·vue
IT_陈寒4 小时前
Java性能调优实战:7个让GC效率提升50%的关键参数设置
前端·人工智能·后端
前端小菜袅5 小时前
uniapp配置自动导入uni生命周期等方法
前端·javascript·uni-app
Apifox5 小时前
如何在 Apifox 中通过 AI 一键生成几十个测试用例?
前端·后端·ai编程
你真的可爱呀5 小时前
uniapp学习【整体实践】
前端·学习·uni-app·实践
一枚前端小能手5 小时前
「周更第7期」实用JS库推荐:Vite
前端·javascript·vite