diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index 1c98290..029b01c 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -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( }, ); +/// 查重结果(单张):匹配到的已上传任务摘要。 +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 duplicates; + + /// 用户选择跳过的重复项张数。 + final int skipped; +} + +/// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded, +/// filename + bytes 匹配的记录。 +/// +/// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。 +Future?> checkDuplicates( + WidgetRef ref, { + required String accountId, + required String albumId, + required List 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 = []; + 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 enqueueForUpload( +/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。 +/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见, +/// 不在此抛出)。 +Future enqueueForUpload( WidgetRef ref, { required String accountId, required String albumId, String? categoryId, required List sourcePaths, + List? skipPaths, }) async { // 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。 await ref.read(notificationPermissionProvider).ensureRequested(); final preparer = ref.read(uploadPreparerProvider); + + // P0-1:跳过用户选择取消的重复项。 + final skipSet = skipPaths?.toSet() ?? {}; + 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 enqueueForUpload( ); if (task.state == UploadTaskState.queued) enqueued++; } - return enqueued; + + return EnqueueResult( + enqueued: enqueued, + duplicates: [], + skipped: sourcePaths.length - effective.length, + ); } diff --git a/apps/mobile/lib/features/uploads/preparer.dart b/apps/mobile/lib/features/uploads/preparer.dart index 01f19db..65b1598 100644 --- a/apps/mobile/lib/features/uploads/preparer.dart +++ b/apps/mobile/lib/features/uploads/preparer.dart @@ -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 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(); diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 0ff1c6c..f8cd2ac 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -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" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index cf8dcaa..ca6e967 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -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 diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 4e2e736..5c51d88 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -1031,4 +1031,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; + + @override + String get albumUploadDisabled => 'Upload is disabled for this album'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index ab476db..17f84d7 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -998,4 +998,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get close => '关闭'; + + @override + String get albumUploadDisabled => '该相册已关闭上传,无法添加照片'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 14a9aae..2e12889 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -427,5 +427,6 @@ "posterDownload": "下载海报图", "posterSaved": "海报已保存到相册", "posterSaveFailed": "海报保存失败", - "close": "关闭" + "close": "关闭", + "albumUploadDisabled": "该相册已关闭上传,无法添加照片" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 0763f27..600dd5f 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -325,24 +325,102 @@ class _AlbumDetailPageState extends ConsumerState { Future _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 skipPaths = {}; + 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?> _showDuplicateDialog( + AppLocalizations l10n, + List duplicates, + List 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( + 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 {}; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index fedf4e5..5673c4f 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -285,7 +285,7 @@ class _CapturePageState extends ConsumerState 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 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 skipPaths; + if (duplicates != null && duplicates.isNotEmpty && mounted) { + // 批量确认弹窗(单次,非逐张)。 + final skip = await _showDuplicateDialog(l10n, duplicates, paths); + if (skip == null) return; // 取消——不上传。 + skipPaths = skip; + } else { + skipPaths = {}; + } + + 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?> _showDuplicateDialog( + AppLocalizations l10n, + List duplicates, + List 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( + 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 {}; // 'all' = 不跳过任何 + } + void _switchCategory(String? categoryId) { if (categoryId == _categoryId) return; setState(() => _categoryId = categoryId);