初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -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'];
}
}