diff --git a/docs/plans/2026-10-22-library-paging-ui.md b/docs/plans/2026-10-22-library-paging-ui.md index 3c151f2..f3c8202 100644 --- a/docs/plans/2026-10-22-library-paging-ui.md +++ b/docs/plans/2026-10-22-library-paging-ui.md @@ -20,3 +20,12 @@ - loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。 - 空页立即停止加载;`loaded >= total` 后不再请求。 - 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。 + +## 2026-10-22 范围修订 + +- 分页协议和 Controller 分页状态保持不改。 +- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。 +- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。 +- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。 +- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页, + 只在 PROGRESS 记录证据,不做前端全量兜底。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index de7ad9f..38e1036 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -628,8 +628,7 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException - 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。 - 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`; 标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。 -- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`; - 服务端新 PageResult 正常解析。 +- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。 - MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。 ### 门禁 @@ -656,3 +655,46 @@ $ flutter test --no-pub 观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。 未新增依赖、未启停服务、未操作真机;现有 `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` 既有改动未提交。 diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index ad19fa8..3eac576 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -32,13 +32,6 @@ class LibraryApi { if (data is Map) { return PageResult.fromJson(data, Drama.fromCardJson); } - if (data is List) { - final list = data - .whereType>() - .map(Drama.fromCardJson) - .toList(growable: false); - return PageResult(total: list.length, list: list); - } return const PageResult(); } diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index a169856..f63b5d9 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -52,13 +52,6 @@ class WatchApi { if (data is Map) { return PageResult.fromJson(data, WatchProgress.fromJson); } - if (data is List) { - final list = data - .whereType>() - .map(WatchProgress.fromJson) - .toList(growable: false); - return PageResult(total: list.length, list: list); - } return const PageResult(); } diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 569de4f..ec70015 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -130,6 +130,15 @@ class WatchRecordsController extends ChangeNotifier { _notify(); } + /// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。 + Future detailForOpen(String dramaId) async { + final cached = _detailCache[dramaId]; + if (cached != null) return cached; + await _loadDetail(dramaId); + _notify(); + return _detailCache[dramaId]; + } + /// 删除单条记录,先更新 UI,失败按原位置回滚。 Future remove(String dramaId) async { final index = _recordIndex(dramaId); @@ -201,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier { } Future _hydrate(List 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); } final queue = [ - for (final id in dramaIds) + for (final id in missingTitles) if (!_detailCache.containsKey(id)) id, ]; final workerCount = queue.length < detailConcurrency @@ -222,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier { ]); } - Future _loadDetail(String dramaId) async { + bool _hasMissingTitle(String dramaId) { final index = _recordIndex(dramaId); - if (index < 0) return; + if (index < 0) return false; + return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty; + } + + Future _loadDetail(String dramaId) async { + final index = _recordIndex(dramaId); + if (index < 0) return null; final cached = _detailCache[dramaId]; if (cached != null) { _replaceAt( @@ -235,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier { detailFailed: false, ), ); - return; + return cached; } try { final detail = await _catalog.fetchDrama(dramaId); - if (_disposed) return; + if (_disposed) return null; _detailCache[dramaId] = detail; final current = _recordIndex(dramaId); - if (current < 0) return; + if (current < 0) return detail; _replaceAt( current, records[current].copyWith( @@ -251,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier { detailFailed: false, ), ); + return detail; } catch (failure, stackTrace) { debugPrint( '[WatchRecordsController] detail failed dramaId=$dramaId ' @@ -260,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier { stackTrace: stackTrace, label: '[WatchRecordsController] detail failed dramaId=$dramaId', ); - if (_disposed) return; + if (_disposed) return null; final current = _recordIndex(dramaId); - if (current < 0) return; + if (current < 0) return null; _replaceAt( current, records[current].copyWith(detailLoading: false, detailFailed: true), ); + return null; } } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 95c291f..c8c326a 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -140,7 +140,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { record: record, onRetry: () => controller.retryDetail(record.dramaId), onRemove: () => _remove(record.dramaId), - onOpen: record.drama == null ? null : () => _open(record), + onOpen: () => _open(record), ), ], PagedListFooter( @@ -219,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Future _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; await playDramaFromCatalog( context, - record.drama!, + drama, episodeId: episodeId.isEmpty ? null : episodeId, ); } diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart index 9973162..ffa5c08 100644 --- a/test/integration/live_watch_flow_test.dart +++ b/test/integration/live_watch_flow_test.dart @@ -58,6 +58,20 @@ void main() { } 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 || + favoriteProbe is! Map) { + markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过'); + return; + } + // 1) 首页 → 取第一部剧 final feed = await catalog.homeFeed(); expect(feed.dramas, isNotEmpty); diff --git a/test/library/library_api_test.dart b/test/library/library_api_test.dart index 26f5c0c..127c61c 100644 --- a/test/library/library_api_test.dart +++ b/test/library/library_api_test.dart @@ -38,29 +38,4 @@ void main() { expect(page.list.single.id, '9'); 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, '旧收藏'); - }, - ); } diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index e9e765e..5369cc3 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -50,34 +50,4 @@ void main() { expect(page.list.single.episodeNumber, 6); 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'); - }, - ); } diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 82ae34f..5a415fa 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -16,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart'; WatchProgress _progress( String dramaId, { String episodeId = 'episode-1', + String? dramaTitle, int positionMs = 12000, int durationMs = 60000, }) => WatchProgress( dramaId: dramaId, episodeId: episodeId, + dramaTitle: dramaTitle, position: Duration(milliseconds: positionMs), duration: Duration(milliseconds: durationMs), updatedAt: DateTime(2026, 10, 17, 12), @@ -74,6 +76,27 @@ void main() { 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 { final history = ControlledWatchHistory([_progress('1'), _progress('2')]); final catalog = ScriptedWatchCatalog(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index f4146da..9b8fc26 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -111,16 +111,13 @@ void main() { episodeNumber: 3, ), ]); + final catalog = _AlwaysFailingWatchCatalog(); - await _pumpPage( - tester, - auth: auth, - history: history, - catalog: _AlwaysFailingWatchCatalog(), - ); + await _pumpPage(tester, auth: auth, history: history, catalog: catalog); expect(find.text('Backend Title'), findsOneWidget); expect(find.text('Episode 3'), findsOneWidget); + expect(catalog.calls, isEmpty); expect( find.byKey(const Key('watch-record-cover-metadata')), findsOneWidget, @@ -421,8 +418,11 @@ class _FailingWatchCatalog implements CatalogRepository { } class _AlwaysFailingWatchCatalog implements CatalogRepository { + final List calls = []; + @override Future fetchDrama(String dramaId) async { + calls.add(dramaId); throw StateError('missing detail'); }