merge: 优化观看历史标题加载
This commit is contained in:
commit
9093700a04
11 files changed
+135
-88
No files matched your search
@@ -20,3 +20,12 @@
|
|||||||
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
|
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
|
||||||
- 空页立即停止加载;`loaded >= total` 后不再请求。
|
- 空页立即停止加载;`loaded >= total` 后不再请求。
|
||||||
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||||
|
|
||||||
|
## 2026-10-22 范围修订
|
||||||
|
|
||||||
|
- 分页协议和 Controller 分页状态保持不改。
|
||||||
|
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
|
||||||
|
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
|
||||||
|
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
|
||||||
|
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
|
||||||
|
只在 PROGRESS 记录证据,不做前端全量兜底。
|
||||||
+44
-2
@@ -628,8 +628,7 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException
|
|||||||
- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。
|
- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。
|
||||||
- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`;
|
- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`;
|
||||||
标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。
|
标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。
|
||||||
- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`;
|
- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。
|
||||||
服务端新 PageResult 正常解析。
|
|
||||||
- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。
|
- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。
|
||||||
|
|
||||||
### 门禁
|
### 门禁
|
||||||
@@ -656,3 +655,46 @@ $ flutter test --no-pub
|
|||||||
观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。
|
观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。
|
||||||
|
|
||||||
未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。
|
未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。
|
||||||
|
|
||||||
|
### 范围修订:观看标题优先与按需详情
|
||||||
|
|
||||||
|
- 分页协议、Repository 与 Controller 分页状态保持不变。
|
||||||
|
- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。
|
||||||
|
- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。
|
||||||
|
- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。
|
||||||
|
- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。
|
||||||
|
|
||||||
|
真实后端分页取证(仅测试账号写入,不启停服务):
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /app/watch/recent?pageIndex=1&pageSize=1
|
||||||
|
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||||
|
GET /app/watch/recent?pageIndex=2&pageSize=1
|
||||||
|
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||||
|
GET /app/favorites?pageIndex=1&pageSize=1
|
||||||
|
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||||
|
GET /app/favorites?pageIndex=2&pageSize=1
|
||||||
|
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页
|
||||||
|
均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped,
|
||||||
|
等待后端新包部署后再执行分页真实联调。
|
||||||
|
|
||||||
|
门禁:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
|
||||||
|
00:02 +22 ~1: All tests passed!
|
||||||
|
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:52 +630 ~1: All tests passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、
|
||||||
|
第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务
|
||||||
|
能力探测跳过。`pubspec.lock` 既有改动未提交。
|
||||||
@@ -32,13 +32,6 @@ class LibraryApi {
|
|||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
return PageResult.fromJson(data, Drama.fromCardJson);
|
return PageResult.fromJson(data, Drama.fromCardJson);
|
||||||
}
|
}
|
||||||
if (data is List) {
|
|
||||||
final list = data
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(Drama.fromCardJson)
|
|
||||||
.toList(growable: false);
|
|
||||||
return PageResult(total: list.length, list: list);
|
|
||||||
}
|
|
||||||
return const PageResult();
|
return const PageResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,6 @@ class WatchApi {
|
|||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
return PageResult.fromJson(data, WatchProgress.fromJson);
|
return PageResult.fromJson(data, WatchProgress.fromJson);
|
||||||
}
|
}
|
||||||
if (data is List) {
|
|
||||||
final list = data
|
|
||||||
.whereType<Map<String, dynamic>>()
|
|
||||||
.map(WatchProgress.fromJson)
|
|
||||||
.toList(growable: false);
|
|
||||||
return PageResult(total: list.length, list: list);
|
|
||||||
}
|
|
||||||
return const PageResult();
|
return const PageResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||||
|
Future<Drama?> detailForOpen(String dramaId) async {
|
||||||
|
final cached = _detailCache[dramaId];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
await _loadDetail(dramaId);
|
||||||
|
_notify();
|
||||||
|
return _detailCache[dramaId];
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||||
Future<bool> remove(String dramaId) async {
|
Future<bool> remove(String dramaId) async {
|
||||||
final index = _recordIndex(dramaId);
|
final index = _recordIndex(dramaId);
|
||||||
@@ -201,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _hydrate(List<String> dramaIds) async {
|
Future<void> _hydrate(List<String> dramaIds) async {
|
||||||
for (final id in dramaIds) {
|
final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList();
|
||||||
|
for (final id in missingTitles) {
|
||||||
if (_detailCache.containsKey(id)) await _loadDetail(id);
|
if (_detailCache.containsKey(id)) await _loadDetail(id);
|
||||||
}
|
}
|
||||||
final queue = [
|
final queue = [
|
||||||
for (final id in dramaIds)
|
for (final id in missingTitles)
|
||||||
if (!_detailCache.containsKey(id)) id,
|
if (!_detailCache.containsKey(id)) id,
|
||||||
];
|
];
|
||||||
final workerCount = queue.length < detailConcurrency
|
final workerCount = queue.length < detailConcurrency
|
||||||
@@ -222,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDetail(String dramaId) async {
|
bool _hasMissingTitle(String dramaId) {
|
||||||
final index = _recordIndex(dramaId);
|
final index = _recordIndex(dramaId);
|
||||||
if (index < 0) return;
|
if (index < 0) return false;
|
||||||
|
return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Drama?> _loadDetail(String dramaId) async {
|
||||||
|
final index = _recordIndex(dramaId);
|
||||||
|
if (index < 0) return null;
|
||||||
final cached = _detailCache[dramaId];
|
final cached = _detailCache[dramaId];
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
_replaceAt(
|
_replaceAt(
|
||||||
@@ -235,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
detailFailed: false,
|
detailFailed: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return cached;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final detail = await _catalog.fetchDrama(dramaId);
|
final detail = await _catalog.fetchDrama(dramaId);
|
||||||
if (_disposed) return;
|
if (_disposed) return null;
|
||||||
_detailCache[dramaId] = detail;
|
_detailCache[dramaId] = detail;
|
||||||
final current = _recordIndex(dramaId);
|
final current = _recordIndex(dramaId);
|
||||||
if (current < 0) return;
|
if (current < 0) return detail;
|
||||||
_replaceAt(
|
_replaceAt(
|
||||||
current,
|
current,
|
||||||
records[current].copyWith(
|
records[current].copyWith(
|
||||||
@@ -251,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
detailFailed: false,
|
detailFailed: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return detail;
|
||||||
} catch (failure, stackTrace) {
|
} catch (failure, stackTrace) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||||
@@ -260,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||||
);
|
);
|
||||||
if (_disposed) return;
|
if (_disposed) return null;
|
||||||
final current = _recordIndex(dramaId);
|
final current = _recordIndex(dramaId);
|
||||||
if (current < 0) return;
|
if (current < 0) return null;
|
||||||
_replaceAt(
|
_replaceAt(
|
||||||
current,
|
current,
|
||||||
records[current].copyWith(detailLoading: false, detailFailed: true),
|
records[current].copyWith(detailLoading: false, detailFailed: true),
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
record: record,
|
record: record,
|
||||||
onRetry: () => controller.retryDetail(record.dramaId),
|
onRetry: () => controller.retryDetail(record.dramaId),
|
||||||
onRemove: () => _remove(record.dramaId),
|
onRemove: () => _remove(record.dramaId),
|
||||||
onOpen: record.drama == null ? null : () => _open(record),
|
onOpen: () => _open(record),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
PagedListFooter(
|
PagedListFooter(
|
||||||
@@ -219,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _open(WatchRecord record) async {
|
Future<void> _open(WatchRecord record) async {
|
||||||
|
final drama =
|
||||||
|
record.drama ?? await controller.detailForOpen(record.dramaId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (drama == null) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
final episodeId = record.progress.episodeId;
|
final episodeId = record.progress.episodeId;
|
||||||
await playDramaFromCatalog(
|
await playDramaFromCatalog(
|
||||||
context,
|
context,
|
||||||
record.drama!,
|
drama,
|
||||||
episodeId: episodeId.isEmpty ? null : episodeId,
|
episodeId: episodeId.isEmpty ? null : episodeId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ void main() {
|
|||||||
}
|
}
|
||||||
expect(session?.user.uid, isNotEmpty);
|
expect(session?.user.uid, isNotEmpty);
|
||||||
|
|
||||||
|
final recentProbe = await client.get(
|
||||||
|
'/app/watch/recent',
|
||||||
|
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||||
|
);
|
||||||
|
final favoriteProbe = await client.get(
|
||||||
|
'/app/favorites',
|
||||||
|
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||||
|
);
|
||||||
|
if (recentProbe is! Map<String, dynamic> ||
|
||||||
|
favoriteProbe is! Map<String, dynamic>) {
|
||||||
|
markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1) 首页 → 取第一部剧
|
// 1) 首页 → 取第一部剧
|
||||||
final feed = await catalog.homeFeed();
|
final feed = await catalog.homeFeed();
|
||||||
expect(feed.dramas, isNotEmpty);
|
expect(feed.dramas, isNotEmpty);
|
||||||
|
|||||||
@@ -38,29 +38,4 @@ void main() {
|
|||||||
expect(page.list.single.id, '9');
|
expect(page.list.single.id, '9');
|
||||||
expect(page.list.single.title, '收藏剧');
|
expect(page.list.single.title, '收藏剧');
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
|
||||||
'should_wrap_legacy_list_when_backend_has_not_deployed_paging',
|
|
||||||
() async {
|
|
||||||
final adapter = FakeHttpAdapter(
|
|
||||||
(_) async => FakeHttpResponse(
|
|
||||||
jsonEncode({
|
|
||||||
'code': 0,
|
|
||||||
'message': 'ok',
|
|
||||||
'data': [
|
|
||||||
{'id': '3', 'title': '旧收藏'},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final api = LibraryApi(
|
|
||||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
|
||||||
);
|
|
||||||
|
|
||||||
final page = await api.favorites();
|
|
||||||
|
|
||||||
expect(page.total, 1);
|
|
||||||
expect(page.list.single.title, '旧收藏');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -50,34 +50,4 @@ void main() {
|
|||||||
expect(page.list.single.episodeNumber, 6);
|
expect(page.list.single.episodeNumber, 6);
|
||||||
expect(page.list.single.totalEpisodes, 68);
|
expect(page.list.single.totalEpisodes, 68);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
|
||||||
'should_wrap_legacy_list_when_backend_has_not_deployed_paging',
|
|
||||||
() async {
|
|
||||||
final adapter = FakeHttpAdapter(
|
|
||||||
(_) async => FakeHttpResponse(
|
|
||||||
jsonEncode({
|
|
||||||
'code': 0,
|
|
||||||
'message': 'ok',
|
|
||||||
'data': [
|
|
||||||
{
|
|
||||||
'dramaId': '5',
|
|
||||||
'episodeId': '7',
|
|
||||||
'positionMs': '1000',
|
|
||||||
'durationMs': '2000',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final api = WatchApi(
|
|
||||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
|
||||||
);
|
|
||||||
|
|
||||||
final page = await api.recent();
|
|
||||||
|
|
||||||
expect(page.total, 1);
|
|
||||||
expect(page.list.single.dramaId, '5');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -16,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart';
|
|||||||
WatchProgress _progress(
|
WatchProgress _progress(
|
||||||
String dramaId, {
|
String dramaId, {
|
||||||
String episodeId = 'episode-1',
|
String episodeId = 'episode-1',
|
||||||
|
String? dramaTitle,
|
||||||
int positionMs = 12000,
|
int positionMs = 12000,
|
||||||
int durationMs = 60000,
|
int durationMs = 60000,
|
||||||
}) => WatchProgress(
|
}) => WatchProgress(
|
||||||
dramaId: dramaId,
|
dramaId: dramaId,
|
||||||
episodeId: episodeId,
|
episodeId: episodeId,
|
||||||
|
dramaTitle: dramaTitle,
|
||||||
position: Duration(milliseconds: positionMs),
|
position: Duration(milliseconds: positionMs),
|
||||||
duration: Duration(milliseconds: durationMs),
|
duration: Duration(milliseconds: durationMs),
|
||||||
updatedAt: DateTime(2026, 10, 17, 12),
|
updatedAt: DateTime(2026, 10, 17, 12),
|
||||||
@@ -74,6 +76,27 @@ void main() {
|
|||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should_use_backend_title_and_skip_detail_until_opened', () async {
|
||||||
|
final history = ControlledWatchHistory([
|
||||||
|
_progress('12', dramaTitle: '后端标题'),
|
||||||
|
]);
|
||||||
|
final catalog = ScriptedWatchCatalog();
|
||||||
|
final controller = WatchRecordsController(
|
||||||
|
history: history,
|
||||||
|
catalog: catalog,
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.records.single.displayTitle, '后端标题');
|
||||||
|
expect(catalog.calls, isEmpty);
|
||||||
|
|
||||||
|
final detail = await controller.detailForOpen('12');
|
||||||
|
|
||||||
|
expect(detail?.id, '12');
|
||||||
|
expect(catalog.calls, ['12']);
|
||||||
|
});
|
||||||
|
|
||||||
test('should_reuse_cached_details_when_records_are_loaded_again', () async {
|
test('should_reuse_cached_details_when_records_are_loaded_again', () async {
|
||||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||||
final catalog = ScriptedWatchCatalog();
|
final catalog = ScriptedWatchCatalog();
|
||||||
|
|||||||
@@ -111,16 +111,13 @@ void main() {
|
|||||||
episodeNumber: 3,
|
episodeNumber: 3,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
final catalog = _AlwaysFailingWatchCatalog();
|
||||||
|
|
||||||
await _pumpPage(
|
await _pumpPage(tester, auth: auth, history: history, catalog: catalog);
|
||||||
tester,
|
|
||||||
auth: auth,
|
|
||||||
history: history,
|
|
||||||
catalog: _AlwaysFailingWatchCatalog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(find.text('Backend Title'), findsOneWidget);
|
expect(find.text('Backend Title'), findsOneWidget);
|
||||||
expect(find.text('Episode 3'), findsOneWidget);
|
expect(find.text('Episode 3'), findsOneWidget);
|
||||||
|
expect(catalog.calls, isEmpty);
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const Key('watch-record-cover-metadata')),
|
find.byKey(const Key('watch-record-cover-metadata')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -421,8 +418,11 @@ class _FailingWatchCatalog implements CatalogRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AlwaysFailingWatchCatalog implements CatalogRepository {
|
class _AlwaysFailingWatchCatalog implements CatalogRepository {
|
||||||
|
final List<String> calls = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Drama> fetchDrama(String dramaId) async {
|
Future<Drama> fetchDrama(String dramaId) async {
|
||||||
|
calls.add(dramaId);
|
||||||
throw StateError('missing detail');
|
throw StateError('missing detail');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user