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
+77 -3
View File
@@ -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);