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:
浅念
2026-09-29 12:04:06 +08:00
parent f1912998a8
commit 3728eb93d9
10 changed files with 141 additions and 18 deletions
+11 -1
View File
@@ -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<String, dynamic> 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<String, dynamic>),
);
/// 照片 ID。
@@ -232,10 +238,14 @@ class UploadSessionPhoto {
/// 对外可见性。
final PhotoVisibility visibility;
/// 短期签名预览 URL;null = 无(回退占位图标)。
final PhotoPreview? preview;
Map<String, dynamic> toJson() => {
'photoId': photoId,
'processingState': processingState.wire,
'visibility': visibility.wire,
if (preview != null) 'preview': preview!.toJson(),
};
}
+2
View File
@@ -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": {
@@ -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:
@@ -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';
@@ -604,6 +604,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get queueEmpty => '暂无上传任务';
@override
String get queueEmptyDeviceHint => '上传记录仅保存在本机,卸载重装后不会保留';
@override
String get queueViewServerHistory => '查看服务端历史';
@override
String get queueRetryAllFailed => '一键重试全部失败';
+2
View File
@@ -230,6 +230,8 @@
"queueTimeLast7Days": "近7天",
"queueTimeLast30Days": "近30天",
"queueEmpty": "暂无上传任务",
"queueEmptyDeviceHint": "上传记录仅保存在本机,卸载重装后不会保留",
"queueViewServerHistory": "查看服务端历史",
"queueRetryAllFailed": "一键重试全部失败",
"queueAttemptCount": "已尝试 {count} 次",
"@queueAttemptCount": {
+13 -3
View File
@@ -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(
+26 -13
View File
@@ -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<ServerHistoryPage> {
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(