初始化
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_utils/file_util.dart';
|
||||
import '../loading/loading_alert_widget.dart';
|
||||
import '../net/http_resp_interceptor.dart';
|
||||
import '../net/net_manager.dart';
|
||||
import 'upload_result_model.dart';
|
||||
|
||||
/// 文件上传工具:图片(压缩后整传)、视频(分片传)
|
||||
class FileUploadTool {
|
||||
// ==================== 配置 ====================
|
||||
|
||||
/// 图片压缩目标体积上限 200KB(尽量压到此值内,触及质量下限则止)
|
||||
static const int maxImageSize = 200 * 1024;
|
||||
|
||||
/// 压缩长边上限(保清晰)
|
||||
static const int maxImageEdge = 1920;
|
||||
|
||||
/// 压缩质量下限(再小也不低于此,避免糊)
|
||||
static const int minCompressQuality = 60;
|
||||
|
||||
/// 视频单片最大重试次数(首传失败后再试 N 次,pos+id 固定,重传幂等)
|
||||
static const int maxChunkRetry = 2;
|
||||
|
||||
// ==================== 图片上传 ====================
|
||||
|
||||
/// 上传单张图片(读本地文件 → 压缩 → 上传),本地文件读不到时返回 null
|
||||
Future<ImageUploadResultModel?> uploadImage(String path,
|
||||
{Function(int, int)? callback}) async {
|
||||
final ext = FileUtil.getNameSuffix(path);
|
||||
final fileName =
|
||||
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.$ext';
|
||||
Uint8List fileData;
|
||||
try {
|
||||
// 相册图被删/路径失效时 readAsBytes 会抛,统一转成 null 失败,别让异常穿到调用方
|
||||
fileData = await File(path).readAsBytes();
|
||||
} catch (e) {
|
||||
debugLog("uploadImage()...read error:$e");
|
||||
return null;
|
||||
}
|
||||
return uploadImageData(fileData, fileName: fileName, callback: callback);
|
||||
}
|
||||
|
||||
/// 上传图片字节数据(压缩后 POST),成功返回结果模型,失败返回 null
|
||||
/// [callback] (已传字节, 总字节)
|
||||
Future<ImageUploadResultModel?> uploadImageData(
|
||||
Uint8List imageData, {
|
||||
String? fileName,
|
||||
Function(int, int)? callback,
|
||||
}) async {
|
||||
final compressedData =
|
||||
await _compressImage(imageData, maxSize: maxImageSize);
|
||||
final name = fileName ??
|
||||
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.jpg';
|
||||
debugLog("开始上传----", name);
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
'upload': MultipartFile.fromBytes(compressedData, filename: name),
|
||||
});
|
||||
final options = await _buildOptions();
|
||||
try {
|
||||
final resp = await createDio().post(
|
||||
"${Address.baseApiPath}${Address.uploadImg}",
|
||||
options: options,
|
||||
data: formData,
|
||||
onSendProgress: (count, total) => callback?.call(count, total),
|
||||
);
|
||||
if (resp.statusCode == 200) {
|
||||
await HttpRespInterceptor.handleResponse(resp);
|
||||
return ImageUploadResultModel.fromMap(resp.data);
|
||||
}
|
||||
return ImageUploadResultModel();
|
||||
} catch (e) {
|
||||
debugLog("uploadImageData()...error:$e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量上传图片(有界并发 + 保序),任一失败立即中断并返回 null
|
||||
/// [maxConcurrent] 最大并发数,默认 5;[onProgress] 整体进度 0.0~1.0(单调递增)
|
||||
Future<List<ImageUploadResultModel>?> uploadImageList(
|
||||
List<String> paths, {
|
||||
int maxConcurrent = 5,
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (paths.isEmpty) return [];
|
||||
|
||||
final results = List<ImageUploadResultModel?>.filled(paths.length, null);
|
||||
final fractions = List<double>.filled(paths.length, 0.0); // 每张的完成度 0~1
|
||||
bool failed = false;
|
||||
|
||||
void reportProgress() {
|
||||
if (onProgress == null) return;
|
||||
final sum = fractions.fold<double>(0, (a, b) => a + b);
|
||||
onProgress(sum / paths.length);
|
||||
}
|
||||
|
||||
// 单 isolate 无真并行,index 领取不会有竞态
|
||||
int nextIndex = 0;
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final i = nextIndex;
|
||||
if (i >= paths.length) return;
|
||||
nextIndex++;
|
||||
|
||||
final model = await uploadImage(paths[i], callback: (sent, total) {
|
||||
fractions[i] = total > 0 ? sent / total : 0;
|
||||
reportProgress();
|
||||
});
|
||||
if (model?.coverImg?.isNotEmpty == true) {
|
||||
results[i] = model;
|
||||
fractions[i] = 1;
|
||||
reportProgress();
|
||||
} else {
|
||||
failed = true; // 任一失败:不再领取新任务
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = min(maxConcurrent, paths.length);
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
|
||||
if (failed) return null;
|
||||
return results.map((e) => e!).toList();
|
||||
}
|
||||
|
||||
/// 批量上传图片,内部自管「上传阶段」的 loading(show → 实时进度 → 结束 cancel)。
|
||||
/// 上传成功回调 [onSuccess](图片 url 列表),失败或结果为空回调 [onFailure]。
|
||||
Future<void> uploadImagesWithProgress(
|
||||
List<String> paths, {
|
||||
String title = "正在上传图片",
|
||||
required Function(List<String> urls) onSuccess,
|
||||
Function()? onFailure,
|
||||
}) async {
|
||||
List<ImageUploadResultModel>? results;
|
||||
LoadingAlertWidget.show(title: "$title...");
|
||||
try {
|
||||
results = await uploadImageList(paths, onProgress: (progress) {
|
||||
LoadingAlertWidget.showExchangeTitle(
|
||||
"$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
});
|
||||
} catch (e) {
|
||||
debugLog(
|
||||
"uploadImagesWithProgress()...error:$e"); // 兜底:异常也要把 loading 关掉,别卡死转圈
|
||||
} finally {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
|
||||
final urls = results?.map((e) => e.coverImg ?? "").toList() ?? [];
|
||||
if (urls.isEmpty) {
|
||||
onFailure?.call();
|
||||
} else {
|
||||
onSuccess(urls);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 视频上传 ====================
|
||||
|
||||
/// 上传视频(分片 POST,有界并发 + 单片失败重试 + 全部成功后回填 md5),失败(文件缺失 / 重试用尽)返回 null
|
||||
/// [maxConcurrent] 最大并发片数,默认 5;[onProgress] 整体进度 0.0~1.0
|
||||
Future<VideoUploadResultModel?> uploadVideo(
|
||||
String localPath, {
|
||||
int maxConcurrent = 5,
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!FileUtil.isFileExist(localPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final videoFile = File(localPath);
|
||||
final fileLen = FileUtil.getFileSize(localPath);
|
||||
final fileBytes = await videoFile.readAsBytes();
|
||||
final fileId = md5.convert(fileBytes).toString();
|
||||
final patchSize = FileUtil.getPatchSize(fileLen);
|
||||
final patchCount = FileUtil.getPatchCount(fileLen);
|
||||
debugLog("分段大小:$patchSize 视频被分成:$patchCount 个片段");
|
||||
|
||||
final options = await _buildOptions(
|
||||
contentType: "application/json",
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
responseType: ResponseType.json,
|
||||
);
|
||||
|
||||
final fractions = List<double>.filled(patchCount, 0.0); // 每片完成度 0~1
|
||||
VideoUploadResultModel? finalResult; // 收尾片(带非空 videoUri)的响应
|
||||
bool failed = false;
|
||||
|
||||
void reportProgress() {
|
||||
if (onProgress == null) return;
|
||||
final sum = fractions.fold<double>(0, (a, b) => a + b);
|
||||
onProgress(sum / patchCount);
|
||||
}
|
||||
|
||||
// 单 isolate 无真并行,index 领取不会有竞态
|
||||
int nextIndex = 0;
|
||||
Future<void> worker() async {
|
||||
while (!failed) {
|
||||
final index = nextIndex;
|
||||
if (index >= patchCount) return;
|
||||
nextIndex++;
|
||||
|
||||
// 每片取 patchSize,最后一片取剩余字节;直接切内存中的 fileBytes(零拷贝视图),避免重读磁盘/开文件句柄
|
||||
final start = index * patchSize;
|
||||
final end = min(start + patchSize, fileBytes.length);
|
||||
// 正常 start 必 < fileBytes.length;越界(文件被截短)时取空块,不抛 RangeError
|
||||
final blockData = start < end
|
||||
? Uint8List.sublistView(fileBytes, start, end)
|
||||
: Uint8List(0);
|
||||
final postData = {
|
||||
'data': base64.encode(blockData),
|
||||
'pos': index + 1,
|
||||
'totalPos': patchCount,
|
||||
'id': fileId,
|
||||
};
|
||||
|
||||
// 单片重试:pos+id 固定,重传幂等;弱网偶发丢片不再整段作废。指数退避 0.5s/1s
|
||||
Object? lastError;
|
||||
for (int attempt = 0; attempt <= maxChunkRetry; attempt++) {
|
||||
if (failed) return; // 其它 worker 已判定失败,无谓再传
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(
|
||||
Duration(milliseconds: 500 * (1 << (attempt - 1))));
|
||||
debugLog("uploadVideo() 第${index + 1}片 第$attempt 次重试");
|
||||
}
|
||||
try {
|
||||
final resp = await createDio().post(
|
||||
Address.baseApiPath! + Address.uploadVideo,
|
||||
options: options,
|
||||
data: postData,
|
||||
onSendProgress: (sent, total) {
|
||||
fractions[index] = total > 0 ? sent / total : 0;
|
||||
reportProgress();
|
||||
},
|
||||
);
|
||||
await HttpRespInterceptor.handleResponse(resp);
|
||||
fractions[index] = 1;
|
||||
reportProgress();
|
||||
// 并发下到达顺序不定,只认带 videoUri 的响应为收尾结果
|
||||
final r = VideoUploadResultModel.fromMap(resp.data);
|
||||
if (r.videoUri?.isNotEmpty == true) {
|
||||
finalResult = r;
|
||||
}
|
||||
lastError = null;
|
||||
break; // 本片成功,跳出重试
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
fractions[index] = 0; // 回退本片进度,避免重试期间整体进度虚高
|
||||
reportProgress();
|
||||
debugLog("uploadVideo() 第${index + 1}片 error(第$attempt 次):$e");
|
||||
}
|
||||
}
|
||||
if (lastError != null) {
|
||||
failed = true; // 重试用尽仍失败:不再领取新片
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final workerCount = min(maxConcurrent, patchCount);
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
|
||||
if (failed) return null;
|
||||
finalResult?.md5 = fileId;
|
||||
return finalResult;
|
||||
} catch (e) {
|
||||
debugLog("uploadVideo()...error:$e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传视频,内部自管「上传阶段」的 loading(show → 实时进度 → 结束 cancel)。
|
||||
/// 上传成功回调 [onSuccess](结果模型),失败(文件缺失/异常)回调 [onFailure]。
|
||||
/// 注意:回调触发前 loading 已 cancel,后续若还需 loading 请在回调内自行 show。
|
||||
Future<void> uploadVideoWithProgress(
|
||||
String localPath, {
|
||||
String title = "正在上传视频",
|
||||
required Function(VideoUploadResultModel result) onSuccess,
|
||||
Function()? onFailure,
|
||||
}) async {
|
||||
LoadingAlertWidget.show(title: "$title...");
|
||||
final result = await uploadVideo(localPath, onProgress: (progress) {
|
||||
LoadingAlertWidget.showExchangeTitle(
|
||||
"$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
});
|
||||
LoadingAlertWidget.cancel();
|
||||
|
||||
if (result != null) {
|
||||
onSuccess(result);
|
||||
} else {
|
||||
onFailure?.call();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
/// 构造 POST 请求配置(默认图片上传用;视频上传覆写 contentType / 超时 / responseType)
|
||||
Future<Options> _buildOptions({
|
||||
String contentType = "*/*", // 暂时让服务器全部接受
|
||||
Duration receiveTimeout = const Duration(seconds: 30),
|
||||
ResponseType? responseType,
|
||||
}) async {
|
||||
return Options(
|
||||
method: "POST",
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: receiveTimeout,
|
||||
contentType: contentType,
|
||||
responseType: responseType,
|
||||
headers: {
|
||||
'User-Agent': await netManager.userAgent(),
|
||||
'Authorization': await netManager.getToken(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 压缩图片:长边限到 maxImageEdge 保清晰,从高质量起逐档降(每次都从原图重压,避免二次劣化),
|
||||
/// 直到体积 ≤ maxSize 或触及质量下限 minCompressQuality(保清晰优先,不无脑压糊)。统一转 JPEG。
|
||||
Future<Uint8List> _compressImage(Uint8List bytes,
|
||||
{required int maxSize}) async {
|
||||
Future<Uint8List> compress(int quality) =>
|
||||
FlutterImageCompress.compressWithList(
|
||||
bytes,
|
||||
minWidth: maxImageEdge,
|
||||
minHeight: maxImageEdge,
|
||||
quality: quality,
|
||||
format: CompressFormat.jpeg,
|
||||
);
|
||||
|
||||
int quality = 90;
|
||||
var result = await compress(quality);
|
||||
while (result.length > maxSize && quality > minCompressQuality) {
|
||||
quality -= 15; // 90 → 75 → 60
|
||||
result = await compress(quality);
|
||||
}
|
||||
debugLog(
|
||||
"图片压缩 ${bytes.length ~/ 1024}KB → ${result.length ~/ 1024}KB (q$quality)");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 图片上传结果
|
||||
class ImageUploadResultModel {
|
||||
String? coverImg; // 图片远程地址
|
||||
|
||||
static ImageUploadResultModel fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return ImageUploadResultModel()..coverImg = map['coverImg'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频上传结果
|
||||
class VideoUploadResultModel {
|
||||
String? id; // 视频资源 id
|
||||
String? videoUri; // 视频远程地址
|
||||
String? md5; // 文件 md5(分片全部上传成功后回填)
|
||||
|
||||
static VideoUploadResultModel fromMap(Map<String, dynamic>? map) {
|
||||
map ??= {};
|
||||
return VideoUploadResultModel()
|
||||
..id = map['id']
|
||||
..videoUri = map['videoUri'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user