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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ class UploadPreparer {
|
||||
Random? random,
|
||||
this.maxBytes = kMaxUploadBytes,
|
||||
this.onEnqueued,
|
||||
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
|
||||
_random = random ?? Random.secure();
|
||||
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final TaskStore _store;
|
||||
final Future<Directory> Function() _baseDirProvider;
|
||||
@@ -88,13 +88,15 @@ class UploadPreparer {
|
||||
required String sourcePath,
|
||||
String? filename,
|
||||
}) {
|
||||
return _serialize(() => _prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: sourcePath,
|
||||
filename: filename,
|
||||
));
|
||||
return _serialize(
|
||||
() => _prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: sourcePath,
|
||||
filename: filename,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 重试准备失败的任务(preparationFailed → preparing → 复制 → queued)。
|
||||
@@ -106,11 +108,10 @@ class UploadPreparer {
|
||||
required String taskId,
|
||||
required String sourcePath,
|
||||
}) {
|
||||
return _serialize(() => _retry(
|
||||
accountId: accountId,
|
||||
taskId: taskId,
|
||||
sourcePath: sourcePath,
|
||||
));
|
||||
return _serialize(
|
||||
() =>
|
||||
_retry(accountId: accountId, taskId: taskId, sourcePath: sourcePath),
|
||||
);
|
||||
}
|
||||
|
||||
/// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。
|
||||
@@ -196,6 +197,11 @@ class UploadPreparer {
|
||||
if (expected <= 0) {
|
||||
throw StateError('empty source');
|
||||
}
|
||||
// P1-8:磁盘空间预检——超 100MB 直接拒绝(copy 阶段的磁盘满错误
|
||||
// 已被下方 on Object 兜底为 copyFailed;此上限防止单文件打满存储)。
|
||||
if (expected > 100 * 1024 * 1024) {
|
||||
throw StateError('source too large');
|
||||
}
|
||||
await target.parent.create(recursive: true);
|
||||
await source.copy(task.sourceFilePath);
|
||||
final copied = await target.length();
|
||||
|
||||
@@ -427,5 +427,6 @@
|
||||
"posterDownload": "Download poster image",
|
||||
"posterSaved": "Poster saved to gallery",
|
||||
"posterSaveFailed": "Failed to save poster",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"albumUploadDisabled": "Upload is disabled for this album"
|
||||
}
|
||||
|
||||
@@ -2017,6 +2017,12 @@ abstract class AppLocalizations {
|
||||
/// In zh, this message translates to:
|
||||
/// **'关闭'**
|
||||
String get close;
|
||||
|
||||
/// No description provided for @albumUploadDisabled.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'该相册已关闭上传,无法添加照片'**
|
||||
String get albumUploadDisabled;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1031,4 +1031,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get close => 'Close';
|
||||
|
||||
@override
|
||||
String get albumUploadDisabled => 'Upload is disabled for this album';
|
||||
}
|
||||
|
||||
@@ -998,4 +998,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get close => '关闭';
|
||||
|
||||
@override
|
||||
String get albumUploadDisabled => '该相册已关闭上传,无法添加照片';
|
||||
}
|
||||
|
||||
@@ -427,5 +427,6 @@
|
||||
"posterDownload": "下载海报图",
|
||||
"posterSaved": "海报已保存到相册",
|
||||
"posterSaveFailed": "海报保存失败",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片"
|
||||
}
|
||||
|
||||
@@ -325,24 +325,102 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
Future<void> _pickFromGallery() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
// P0-8:相册上传开关预检——关闭时不入队(避免白做功后 403)。
|
||||
if (!album.uploadEnabled) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).albumUploadDisabled),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final account = await _currentAccountId();
|
||||
if (account == null) return;
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final count = await enqueueForUpload(
|
||||
|
||||
// P0-1 层 1:入队前查重。
|
||||
final duplicates = await checkDuplicates(
|
||||
ref,
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
sourcePaths: paths,
|
||||
);
|
||||
|
||||
Set<String> skipPaths = <String>{};
|
||||
if (duplicates != null && duplicates.isNotEmpty && mounted) {
|
||||
final skip = await _showDuplicateDialog(l10n, duplicates, paths);
|
||||
if (skip == null) return; // 取消。
|
||||
skipPaths = skip;
|
||||
}
|
||||
|
||||
final result = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
sourcePaths: paths,
|
||||
skipPaths: skipPaths.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.skipped > 0
|
||||
? '${result.enqueued} 张已入队,${result.skipped} 张重复已跳过'
|
||||
: l10n.uploadEnqueuedCount(result.enqueued),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 批量重复确认弹窗(P0-1):返回 null=取消 / 空 Set=全部上传 / 非空=跳过项。
|
||||
Future<Set<String>?> _showDuplicateDialog(
|
||||
AppLocalizations l10n,
|
||||
List<DuplicateMatch> duplicates,
|
||||
List<String> allPaths,
|
||||
) async {
|
||||
final dupFilenames = duplicates.map((d) => d.filename).toSet();
|
||||
final dupPaths = allPaths
|
||||
.where((p) => dupFilenames.contains(p.split('/').last))
|
||||
.toSet();
|
||||
final nonDupCount = allPaths.length - dupPaths.length;
|
||||
|
||||
final choice = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('可能已上传过'),
|
||||
content: Text(
|
||||
'${duplicates.length} 张照片可能已上传到该相册'
|
||||
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
|
||||
'${duplicates.take(3).map((d) => d.filename).join('、')}'
|
||||
'${duplicates.length > 3 ? ' …' : ''}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('cancel'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
if (nonDupCount > 0)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('skip'),
|
||||
child: Text('跳过重复,上传 $nonDupCount 张'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('all'),
|
||||
child: Text('全部上传 ${allPaths.length} 张'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (choice == 'cancel' || choice == null) return null;
|
||||
if (choice == 'skip') return dupPaths;
|
||||
return <String>{};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
@@ -285,7 +285,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
|
||||
sourcePaths: [photo.path],
|
||||
);
|
||||
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
|
||||
if (enqueued > 0 && mounted) _flashEnqueued();
|
||||
if (enqueued.enqueued > 0 && mounted) _flashEnqueued();
|
||||
})
|
||||
.catchError((Object _) {
|
||||
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
|
||||
@@ -308,19 +308,93 @@ class _CapturePageState extends ConsumerState<CapturePage>
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final count = await enqueueForUpload(
|
||||
|
||||
// P0-1 层 1:入队前查重(同相册已完成的 filename+bytes 匹配)。
|
||||
final duplicates = await checkDuplicates(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
sourcePaths: paths,
|
||||
);
|
||||
|
||||
Set<String> skipPaths;
|
||||
if (duplicates != null && duplicates.isNotEmpty && mounted) {
|
||||
// 批量确认弹窗(单次,非逐张)。
|
||||
final skip = await _showDuplicateDialog(l10n, duplicates, paths);
|
||||
if (skip == null) return; // 取消——不上传。
|
||||
skipPaths = skip;
|
||||
} else {
|
||||
skipPaths = <String>{};
|
||||
}
|
||||
|
||||
final result = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
categoryId: _categoryId,
|
||||
sourcePaths: paths,
|
||||
skipPaths: skipPaths.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
final uploaded = result.enqueued;
|
||||
final skipped = result.skipped;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
skipped > 0
|
||||
? '$uploaded 张已入队,$skipped 张重复已跳过'
|
||||
: l10n.uploadEnqueuedCount(uploaded),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 批量重复确认弹窗:返回 null=取消 / 空 Set=全部上传 / 非空=跳过项路径集。
|
||||
Future<Set<String>?> _showDuplicateDialog(
|
||||
AppLocalizations l10n,
|
||||
List<DuplicateMatch> duplicates,
|
||||
List<String> allPaths,
|
||||
) async {
|
||||
// 找出与 duplicates 匹配的路径(filename 一致即视为重复项)。
|
||||
final dupFilenames = duplicates.map((d) => d.filename).toSet();
|
||||
final dupPaths = allPaths
|
||||
.where((p) => dupFilenames.contains(p.split('/').last))
|
||||
.toSet();
|
||||
final nonDupCount = allPaths.length - dupPaths.length;
|
||||
|
||||
final choice = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('可能已上传过'),
|
||||
content: Text(
|
||||
'${duplicates.length} 张照片可能已上传到该相册'
|
||||
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
|
||||
'${duplicates.take(3).map((d) => d.filename).join('、')}'
|
||||
'${duplicates.length > 3 ? ' …' : ''}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('cancel'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
if (nonDupCount > 0)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('skip'),
|
||||
child: Text('跳过重复,上传 $nonDupCount 张'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop('all'),
|
||||
child: Text('全部上传 ${allPaths.length} 张'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (choice == 'cancel' || choice == null) return null;
|
||||
if (choice == 'skip') return dupPaths;
|
||||
return <String>{}; // 'all' = 不跳过任何
|
||||
}
|
||||
|
||||
void _switchCategory(String? categoryId) {
|
||||
if (categoryId == _categoryId) return;
|
||||
setState(() => _categoryId = categoryId);
|
||||
|
||||
Reference in New Issue
Block a user