feat(app): 服务端历史行首缩略图 + 上传中心空态引导至服务端历史
- UploadSessionPhoto 解析可选 preview;历史行首有 thumb 渲染 PhotoThumbnail (SizedBox 定寸——Row 内裸 AspectRatio 无界约束),缺省回退中性占位 - 上传中心整体空态加「仅本机存储」说明 + 「查看服务端历史」tonal 按钮 (与右上 History 同路由);「需关注」空态保持专属文案不加引导 - l10n 增 queueEmptyDeviceHint / queueViewServerHistory Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -207,12 +207,15 @@ class UploadCompleteResponse {
|
|||||||
|
|
||||||
/// 会话关联照片的当前状态(会话记录内嵌,客户端据此展示服务端进度)。
|
/// 会话关联照片的当前状态(会话记录内嵌,客户端据此展示服务端进度)。
|
||||||
///
|
///
|
||||||
/// 契约 `components.schemas.UploadSessionPhoto`。
|
/// 契约 `components.schemas.UploadSessionPhoto`。[preview] 为可选缩略图
|
||||||
|
///(2026-09-29 增补):服务端在变体齐备且未隐藏/删除时现签(uploader
|
||||||
|
/// 本人视角);缺省(未处理/隐藏/删除)时 UI 回退中性占位。
|
||||||
class UploadSessionPhoto {
|
class UploadSessionPhoto {
|
||||||
const UploadSessionPhoto({
|
const UploadSessionPhoto({
|
||||||
required this.photoId,
|
required this.photoId,
|
||||||
required this.processingState,
|
required this.processingState,
|
||||||
required this.visibility,
|
required this.visibility,
|
||||||
|
this.preview,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UploadSessionPhoto.fromJson(Map<String, dynamic> json) =>
|
factory UploadSessionPhoto.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -221,6 +224,9 @@ class UploadSessionPhoto {
|
|||||||
processingState:
|
processingState:
|
||||||
PhotoProcessingState.fromJson(json['processingState'] as String),
|
PhotoProcessingState.fromJson(json['processingState'] as String),
|
||||||
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
|
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
|
||||||
|
preview: json['preview'] == null
|
||||||
|
? null
|
||||||
|
: PhotoPreview.fromJson(json['preview'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 照片 ID。
|
/// 照片 ID。
|
||||||
@@ -232,10 +238,14 @@ class UploadSessionPhoto {
|
|||||||
/// 对外可见性。
|
/// 对外可见性。
|
||||||
final PhotoVisibility visibility;
|
final PhotoVisibility visibility;
|
||||||
|
|
||||||
|
/// 短期签名预览 URL;null = 无(回退占位图标)。
|
||||||
|
final PhotoPreview? preview;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'photoId': photoId,
|
'photoId': photoId,
|
||||||
'processingState': processingState.wire,
|
'processingState': processingState.wire,
|
||||||
'visibility': visibility.wire,
|
'visibility': visibility.wire,
|
||||||
|
if (preview != null) 'preview': preview!.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,8 @@
|
|||||||
"queueTimeLast7Days": "Last 7 days",
|
"queueTimeLast7Days": "Last 7 days",
|
||||||
"queueTimeLast30Days": "Last 30 days",
|
"queueTimeLast30Days": "Last 30 days",
|
||||||
"queueEmpty": "No upload tasks",
|
"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",
|
"queueRetryAllFailed": "Retry all failed",
|
||||||
"queueAttemptCount": "{count} attempt(s)",
|
"queueAttemptCount": "{count} attempt(s)",
|
||||||
"@queueAttemptCount": {
|
"@queueAttemptCount": {
|
||||||
|
|||||||
@@ -1268,6 +1268,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'暂无上传任务'**
|
/// **'暂无上传任务'**
|
||||||
String get queueEmpty;
|
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.
|
/// No description provided for @queueRetryAllFailed.
|
||||||
///
|
///
|
||||||
/// In zh, this message translates to:
|
/// In zh, this message translates to:
|
||||||
|
|||||||
@@ -622,6 +622,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No upload tasks';
|
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
|
@override
|
||||||
String get queueRetryAllFailed => 'Retry all failed';
|
String get queueRetryAllFailed => 'Retry all failed';
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get queueEmpty => '暂无上传任务';
|
String get queueEmpty => '暂无上传任务';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyDeviceHint => '上传记录仅保存在本机,卸载重装后不会保留';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueViewServerHistory => '查看服务端历史';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueRetryAllFailed => '一键重试全部失败';
|
String get queueRetryAllFailed => '一键重试全部失败';
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,8 @@
|
|||||||
"queueTimeLast7Days": "近7天",
|
"queueTimeLast7Days": "近7天",
|
||||||
"queueTimeLast30Days": "近30天",
|
"queueTimeLast30Days": "近30天",
|
||||||
"queueEmpty": "暂无上传任务",
|
"queueEmpty": "暂无上传任务",
|
||||||
|
"queueEmptyDeviceHint": "上传记录仅保存在本机,卸载重装后不会保留",
|
||||||
|
"queueViewServerHistory": "查看服务端历史",
|
||||||
"queueRetryAllFailed": "一键重试全部失败",
|
"queueRetryAllFailed": "一键重试全部失败",
|
||||||
"queueAttemptCount": "已尝试 {count} 次",
|
"queueAttemptCount": "已尝试 {count} 次",
|
||||||
"@queueAttemptCount": {
|
"@queueAttemptCount": {
|
||||||
|
|||||||
@@ -427,14 +427,24 @@ class _TaskListView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
data: (tasks) {
|
data: (tasks) {
|
||||||
if (tasks.isEmpty) {
|
if (tasks.isEmpty) {
|
||||||
// 页面 15:异常视图空态专用文案(区别于整体队列空态)。
|
// 页面 15:异常视图空态专用文案(区别于整体队列空态);整体空态
|
||||||
|
// 附「本机口径说明 + 服务端历史」引导(卸载重装后本机库为空)。
|
||||||
|
final needsAttentionEmpty =
|
||||||
|
stateFilter == QueueStateFilter.needsAttention;
|
||||||
return EmptyState(
|
return EmptyState(
|
||||||
icon: stateFilter == QueueStateFilter.needsAttention
|
icon: needsAttentionEmpty
|
||||||
? Icons.task_alt_outlined
|
? Icons.task_alt_outlined
|
||||||
: Icons.cloud_upload_outlined,
|
: Icons.cloud_upload_outlined,
|
||||||
title: stateFilter == QueueStateFilter.needsAttention
|
title: needsAttentionEmpty
|
||||||
? l10n.uploadsNoNeedsAttention
|
? l10n.uploadsNoNeedsAttention
|
||||||
: l10n.queueEmpty,
|
: l10n.queueEmpty,
|
||||||
|
subtitle: needsAttentionEmpty ? null : l10n.queueEmptyDeviceHint,
|
||||||
|
actionLabel: needsAttentionEmpty
|
||||||
|
? null
|
||||||
|
: l10n.queueViewServerHistory,
|
||||||
|
onAction: needsAttentionEmpty
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pushNamed('/server-history'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import '../features/uploads/models.dart';
|
|||||||
import '../features/uploads/queue_controller.dart';
|
import '../features/uploads/queue_controller.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../ui/components/empty_state.dart';
|
import '../ui/components/empty_state.dart';
|
||||||
|
import '../ui/components/photo_thumbnail.dart';
|
||||||
import '../ui/components/error_state.dart';
|
import '../ui/components/error_state.dart';
|
||||||
import '../ui/components/state_badge.dart';
|
import '../ui/components/state_badge.dart';
|
||||||
import '../ui/tokens.dart';
|
import '../ui/tokens.dart';
|
||||||
@@ -381,7 +382,19 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// 契约无图片 URL——中性占位(不伪造缩略图)。
|
// 行首 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(
|
Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -279,6 +279,28 @@ void main() {
|
|||||||
reason: 'History 入口 push /server-history(与本机队列数据源区分)');
|
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('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
|
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await seed('done1.jpg', state: UploadTaskState.completed);
|
await seed('done1.jpg', state: UploadTaskState.completed);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。
|
/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。
|
||||||
///
|
///
|
||||||
/// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容
|
/// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容
|
||||||
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与
|
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、行首缩略图
|
||||||
|
/// (有 preview 渲染 PhotoThumbnail;无 preview 回退中性占位)、相册与
|
||||||
/// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、
|
/// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、
|
||||||
/// 首屏错误重试。
|
/// 首屏错误重试。
|
||||||
library;
|
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/core/session_store.dart';
|
||||||
import 'package:photo_live/l10n/app_localizations.dart';
|
import 'package:photo_live/l10n/app_localizations.dart';
|
||||||
import 'package:photo_live/pages/server_history_page.dart';
|
import 'package:photo_live/pages/server_history_page.dart';
|
||||||
|
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||||
|
|
||||||
import '../helpers/fakes.dart';
|
import '../helpers/fakes.dart';
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ Map<String, dynamic> sessionJson({
|
|||||||
String photoId = 'pho-0001',
|
String photoId = 'pho-0001',
|
||||||
String processingState = 'ready',
|
String processingState = 'ready',
|
||||||
String? sourceDeviceId = _kOwnDevice,
|
String? sourceDeviceId = _kOwnDevice,
|
||||||
|
Map<String, dynamic>? preview,
|
||||||
}) =>
|
}) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'uploadId': uploadId,
|
'uploadId': uploadId,
|
||||||
@@ -57,6 +60,7 @@ Map<String, dynamic> sessionJson({
|
|||||||
'photoId': photoId,
|
'photoId': photoId,
|
||||||
'processingState': processingState,
|
'processingState': processingState,
|
||||||
'visibility': 'published',
|
'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('相册筛选(候选来自相册列表;客户端过滤已加载记录)',
|
testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
final adapter = adapterOf(
|
final adapter = adapterOf(
|
||||||
|
|||||||
Reference in New Issue
Block a user