diff --git a/apps/mobile/lib/features/uploads/models.dart b/apps/mobile/lib/features/uploads/models.dart index a50bbd8..fdaf193 100644 --- a/apps/mobile/lib/features/uploads/models.dart +++ b/apps/mobile/lib/features/uploads/models.dart @@ -207,12 +207,15 @@ class UploadCompleteResponse { /// 会话关联照片的当前状态(会话记录内嵌,客户端据此展示服务端进度)。 /// -/// 契约 `components.schemas.UploadSessionPhoto`。 +/// 契约 `components.schemas.UploadSessionPhoto`。[preview] 为可选缩略图 +///(2026-09-29 增补):服务端在变体齐备且未隐藏/删除时现签(uploader +/// 本人视角);缺省(未处理/隐藏/删除)时 UI 回退中性占位。 class UploadSessionPhoto { const UploadSessionPhoto({ required this.photoId, required this.processingState, required this.visibility, + this.preview, }); factory UploadSessionPhoto.fromJson(Map json) => @@ -221,6 +224,9 @@ class UploadSessionPhoto { processingState: PhotoProcessingState.fromJson(json['processingState'] as String), visibility: PhotoVisibility.fromJson(json['visibility'] as String), + preview: json['preview'] == null + ? null + : PhotoPreview.fromJson(json['preview'] as Map), ); /// 照片 ID。 @@ -232,10 +238,14 @@ class UploadSessionPhoto { /// 对外可见性。 final PhotoVisibility visibility; + /// 短期签名预览 URL;null = 无(回退占位图标)。 + final PhotoPreview? preview; + Map toJson() => { 'photoId': photoId, 'processingState': processingState.wire, 'visibility': visibility.wire, + if (preview != null) 'preview': preview!.toJson(), }; } diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 0502023..909939b 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -230,6 +230,8 @@ "queueTimeLast7Days": "Last 7 days", "queueTimeLast30Days": "Last 30 days", "queueEmpty": "No upload tasks", + "queueEmptyDeviceHint": "Upload records live on this device only and are cleared on reinstall", + "queueViewServerHistory": "View server history", "queueRetryAllFailed": "Retry all failed", "queueAttemptCount": "{count} attempt(s)", "@queueAttemptCount": { diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 55255a4..3901577 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1268,6 +1268,18 @@ abstract class AppLocalizations { /// **'暂无上传任务'** String get queueEmpty; + /// No description provided for @queueEmptyDeviceHint. + /// + /// In zh, this message translates to: + /// **'上传记录仅保存在本机,卸载重装后不会保留'** + String get queueEmptyDeviceHint; + + /// No description provided for @queueViewServerHistory. + /// + /// In zh, this message translates to: + /// **'查看服务端历史'** + String get queueViewServerHistory; + /// No description provided for @queueRetryAllFailed. /// /// In zh, this message translates to: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index da7f430..f61e04f 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -622,6 +622,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get queueEmpty => 'No upload tasks'; + @override + String get queueEmptyDeviceHint => + 'Upload records live on this device only and are cleared on reinstall'; + + @override + String get queueViewServerHistory => 'View server history'; + @override String get queueRetryAllFailed => 'Retry all failed'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index e030a94..ca3a121 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -604,6 +604,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get queueEmpty => '暂无上传任务'; + @override + String get queueEmptyDeviceHint => '上传记录仅保存在本机,卸载重装后不会保留'; + + @override + String get queueViewServerHistory => '查看服务端历史'; + @override String get queueRetryAllFailed => '一键重试全部失败'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 26fb199..21d616e 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -230,6 +230,8 @@ "queueTimeLast7Days": "近7天", "queueTimeLast30Days": "近30天", "queueEmpty": "暂无上传任务", + "queueEmptyDeviceHint": "上传记录仅保存在本机,卸载重装后不会保留", + "queueViewServerHistory": "查看服务端历史", "queueRetryAllFailed": "一键重试全部失败", "queueAttemptCount": "已尝试 {count} 次", "@queueAttemptCount": { diff --git a/apps/mobile/lib/pages/queue_tab.dart b/apps/mobile/lib/pages/queue_tab.dart index c6a57e5..500aaa8 100644 --- a/apps/mobile/lib/pages/queue_tab.dart +++ b/apps/mobile/lib/pages/queue_tab.dart @@ -427,14 +427,24 @@ class _TaskListView extends ConsumerWidget { ), data: (tasks) { if (tasks.isEmpty) { - // 页面 15:异常视图空态专用文案(区别于整体队列空态)。 + // 页面 15:异常视图空态专用文案(区别于整体队列空态);整体空态 + // 附「本机口径说明 + 服务端历史」引导(卸载重装后本机库为空)。 + final needsAttentionEmpty = + stateFilter == QueueStateFilter.needsAttention; return EmptyState( - icon: stateFilter == QueueStateFilter.needsAttention + icon: needsAttentionEmpty ? Icons.task_alt_outlined : Icons.cloud_upload_outlined, - title: stateFilter == QueueStateFilter.needsAttention + title: needsAttentionEmpty ? l10n.uploadsNoNeedsAttention : l10n.queueEmpty, + subtitle: needsAttentionEmpty ? null : l10n.queueEmptyDeviceHint, + actionLabel: needsAttentionEmpty + ? null + : l10n.queueViewServerHistory, + onAction: needsAttentionEmpty + ? null + : () => Navigator.of(context).pushNamed('/server-history'), ); } return ListView.builder( diff --git a/apps/mobile/lib/pages/server_history_page.dart b/apps/mobile/lib/pages/server_history_page.dart index 69de5e1..25d4b91 100644 --- a/apps/mobile/lib/pages/server_history_page.dart +++ b/apps/mobile/lib/pages/server_history_page.dart @@ -30,6 +30,7 @@ import '../features/uploads/models.dart'; import '../features/uploads/queue_controller.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/empty_state.dart'; +import '../ui/components/photo_thumbnail.dart'; import '../ui/components/error_state.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; @@ -381,20 +382,32 @@ class _ServerHistoryPageState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // 契约无图片 URL——中性占位(不伪造缩略图)。 - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(kRadiusBadge), + // 行首 48×48:有 preview(变体齐备且未隐藏/删除,服务端现签)→ + // 真实缩略图;缺省 → 中性占位图标(不伪造缩略图)。SizedBox 定 + // 尺寸——Row 内直接放 AspectRatio 是无界约束。 + if (record.photo.preview?.thumb.url case final String thumbUrl) + SizedBox( + width: 48, + height: 48, + child: ClipRRect( + borderRadius: BorderRadius.circular(kRadiusBadge), + child: PhotoThumbnail(url: thumbUrl, aspectRatio: 1), + ), + ) + else + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(kRadiusBadge), + ), + child: Icon( + Icons.cloud_done_outlined, + size: 24, + color: theme.colorScheme.onSurfaceVariant, + ), ), - child: Icon( - Icons.cloud_done_outlined, - size: 24, - color: theme.colorScheme.onSurfaceVariant, - ), - ), const SizedBox(width: kSpace12), Expanded( child: Column( diff --git a/apps/mobile/test/pages/queue_tab_test.dart b/apps/mobile/test/pages/queue_tab_test.dart index a7b57e9..43ea0a6 100644 --- a/apps/mobile/test/pages/queue_tab_test.dart +++ b/apps/mobile/test/pages/queue_tab_test.dart @@ -279,6 +279,28 @@ void main() { reason: 'History 入口 push /server-history(与本机队列数据源区分)'); }); + testWidgets('空态引导:本机 0 条记录 → 服务端历史说明 + 跳转按钮', ( + tester, + ) async { + // setUp 每次新内存库:不种任务即整体队列空态(重装后场景)。 + await pumpQueue(tester); + + expect(find.text('No upload tasks'), findsOneWidget); + // 本机存储口径说明 + 服务端历史引导(EmptyState subtitle + tonal 按钮)。 + expect( + find.text( + 'Upload records live on this device only and are cleared on reinstall', + ), + findsOneWidget, + ); + expect(find.text('View server history'), findsOneWidget); + + await tester.tap(find.text('View server history')); + await tester.pumpAndSettle(); + expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget, + reason: '空态引导与右上 History 同路由'); + }); + testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)', (tester) async { await seed('done1.jpg', state: UploadTaskState.completed); diff --git a/apps/mobile/test/pages/server_history_page_test.dart b/apps/mobile/test/pages/server_history_page_test.dart index 8e0237b..cf08314 100644 --- a/apps/mobile/test/pages/server_history_page_test.dart +++ b/apps/mobile/test/pages/server_history_page_test.dart @@ -1,7 +1,8 @@ /// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。 /// /// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容 -/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与 +/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、行首缩略图 +/// (有 preview 渲染 PhotoThumbnail;无 preview 回退中性占位)、相册与 /// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、 /// 首屏错误重试。 library; @@ -15,6 +16,7 @@ import 'package:photo_live/core/device_id.dart'; import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/server_history_page.dart'; +import 'package:photo_live/ui/components/photo_thumbnail.dart'; import '../helpers/fakes.dart'; @@ -43,6 +45,7 @@ Map sessionJson({ String photoId = 'pho-0001', String processingState = 'ready', String? sourceDeviceId = _kOwnDevice, + Map? preview, }) => { 'uploadId': uploadId, @@ -57,6 +60,7 @@ Map sessionJson({ 'photoId': photoId, 'processingState': processingState, 'visibility': 'published', + 'preview': ?preview, }, }; @@ -182,6 +186,41 @@ void main() { ); }); + testWidgets('行首缩略图:有 preview 渲染 PhotoThumbnail;无 preview 回退中性占位', ( + tester, + ) async { + final adapter = adapterOf((o) { + if (o.method == 'GET' && o.path == '/v1/uploads') { + return ( + 200, + cursorPageBody([ + // 已处理照片(变体齐备)→ 服务端下发 preview。 + sessionJson( + clientTaskId: 'withthumb000000000000000000000001', + preview: photoPreviewJson( + thumbUrl: 'http://test.local/thumb-1.jpg', + ), + ), + // 未处理会话 → 无 preview 字段(App 回退占位图标)。 + sessionJson( + uploadId: 'upl-0002', + clientTaskId: 'nopreview000000000000000000000002', + state: 'pending', + processingState: 'processing', + ), + ]), + ); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + + await pumpPage(tester, clientOf(adapter)); + + // 有 preview:行首是缩略图组件;无 preview:中性占位图标仍在。 + expect(find.byType(PhotoThumbnail), findsOneWidget); + expect(find.byIcon(Icons.cloud_done_outlined), findsOneWidget); + }); + testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)', (tester) async { final adapter = adapterOf(