feat(app): P0 batch 2+2.5 — local upload dedup with batch dialog, album switch check, file size guard

P0-1 层 1 本地查重:
  - checkDuplicates:同账号+同相册+state=completed+filename+bytes 匹配
  - EnqueueResult 返回 enqueued+duplicates+skipped
  - capture_page + album_detail_page:批量单次弹窗(非逐张)三选一
    [跳过重复上传非重复] [全部上传] [取消]

P0-8 相册上传开关预检:album_detail_page 入队前检查 uploadEnabled,
  关闭时提示「该相册已关闭上传」+ 不入队

P1-8 文件大小预检:preparer 限单文件 ≤100MB(磁盘满由 on Object 兜底)

P0-7 更正:上传并发限制已有(kMaxParallelUploads=2),v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 11:06:40 +08:00
parent 9edcb3f2b1
commit 638dd9b45d
9 changed files with 302 additions and 26 deletions
+109 -5
View File
@@ -5,10 +5,17 @@
/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用
/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经
/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。
///
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
library;
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import '../../core/notification_permission.dart';
import 'providers.dart';
@@ -36,22 +43,114 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
},
);
/// 查重结果(单张):匹配到的已上传任务摘要。
class DuplicateMatch {
const DuplicateMatch({
required this.filename,
required this.bytes,
required this.uploadedAt,
});
final String filename;
final int bytes;
final DateTime uploadedAt;
}
/// 入队结果(P0-1:含查重信息)。
class EnqueueResult {
const EnqueueResult({
required this.enqueued,
required this.duplicates,
this.skipped = 0,
});
/// 成功入队张数(state=queued)。
final int enqueued;
/// 与历史已上传任务匹配的项(需用户确认)。
final List<DuplicateMatch> duplicates;
/// 用户选择跳过的重复项张数。
final int skipped;
}
/// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded,
/// filename + bytes 匹配的记录。
///
/// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。
Future<List<DuplicateMatch>?> checkDuplicates(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<String> sourcePaths,
}) async {
if (sourcePaths.isEmpty) return null;
// 收集本次选择的 filename + bytes。
final picks = <({String filename, int bytes})>[];
for (final path in sourcePaths) {
try {
final file = File(path);
picks.add((filename: p.basename(path), bytes: await file.length()));
} on Object {
// 读不到的文件不参与查重(入队时准备器会报错)。
}
}
if (picks.isEmpty) return null;
// 查同相册已完成的任务。
final store = ref.read(taskStoreProvider);
final completed = await store.listByAccount(
accountId,
albumId: albumId,
states: {UploadTaskState.completed},
);
// 匹配 filename + bytes。
final matches = <DuplicateMatch>[];
for (final task in completed) {
for (final pick in picks) {
if (task.filename == pick.filename && task.bytes == pick.bytes) {
matches.add(
DuplicateMatch(
filename: pick.filename,
bytes: pick.bytes,
uploadedAt: task.createdAt,
),
);
break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。
}
}
}
return matches.isEmpty ? null : matches;
}
/// 批量入队:通知权限单次请求 + 逐张准备入队。
///
/// 返回成功入队(state=queued)的张数;准备失败的任务已落库为
/// preparationFailed(连拍页失败徽章/队列页可见,不在此抛出)。
Future<int> enqueueForUpload(
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
/// 不在此抛出)。
Future<EnqueueResult> enqueueForUpload(
WidgetRef ref, {
required String accountId,
required String albumId,
String? categoryId,
required List<String> sourcePaths,
List<String>? skipPaths,
}) async {
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
await ref.read(notificationPermissionProvider).ensureRequested();
final preparer = ref.read(uploadPreparerProvider);
// P0-1:跳过用户选择取消的重复项。
final skipSet = skipPaths?.toSet() ?? <String>{};
final effective = sourcePaths
.where((path) => !skipSet.contains(path))
.toList();
var enqueued = 0;
for (final path in sourcePaths) {
for (final path in effective) {
final task = await preparer.prepare(
accountId: accountId,
albumId: albumId,
@@ -60,5 +159,10 @@ Future<int> enqueueForUpload(
);
if (task.state == UploadTaskState.queued) enqueued++;
}
return enqueued;
return EnqueueResult(
enqueued: enqueued,
duplicates: [],
skipped: sourcePaths.length - effective.length,
);
}