From 59fbd33059bed0763f79839c7cb5605174d63798 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 2 Nov 2026 14:44:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(category):=20=E5=88=86=E7=B1=BB=E9=A2=91?= =?UTF-8?q?=E9=81=93=E6=8E=A5=E5=85=A5=E6=A6=9C=E5=8D=95=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 4 +- lib/core/data/models/category_ranking.dart | 67 +++++++++++++++ lib/core/data/models/content_category.dart | 20 ++++- lib/core/data/remote/category_api.dart | 12 ++- .../data/services/category_controller.dart | 48 ++++++++++- lib/pages/home/widgets/home_feed_grid.dart | 8 ++ lib/pages/home/widgets/home_grid_header.dart | 4 + test/category/category_controller_test.dart | 82 ++++++++++++++++++- 8 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 lib/core/data/models/category_ranking.dart diff --git a/lib/app.dart b/lib/app.dart index 8d547c4..47ea1e3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -268,7 +268,9 @@ class _LumaDramaAppState extends State { CategoryController _createCategories() { final controller = CategoryController(_categoryRepository); if (widget.catalogRepository == null) { - controller.dramaPageLoader = CategoryApi(_apiClient).dramas; + final api = CategoryApi(_apiClient); + controller.dramaPageLoader = api.dramas; + controller.rankingLoader = api.ranking; } controller.load(); return controller; diff --git a/lib/core/data/models/category_ranking.dart b/lib/core/data/models/category_ranking.dart new file mode 100644 index 0000000..4b73d44 --- /dev/null +++ b/lib/core/data/models/category_ranking.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'drama.dart'; +import 'locale_text.dart'; + +/// 服务端推荐榜快照;顺序和排名均不可在客户端重算。 +class CategoryRanking { + const CategoryRanking({ + required this.id, + required this.code, + required this.revision, + required this.entries, + this.locales = const {}, + this.generatedAt, + }); + + final String id; + final String code; + final String revision; + final Map locales; + final DateTime? generatedAt; + final List entries; + + /// 解析 `GET /app/categories/{id}/ranking`。 + factory CategoryRanking.fromJson(Map json) { + final rawList = json['list']; + final entries = rawList is List + ? rawList + .whereType() + .map( + (item) => + CategoryRankingEntry.fromJson(item.cast()), + ) + .toList(growable: false) + : const []; + return CategoryRanking( + id: (json['id'] ?? '').toString(), + code: json['code'] as String? ?? '', + revision: (json['revision'] ?? '1').toString(), + locales: localeMap(json['locales']), + generatedAt: DateTime.tryParse(json['generatedAt'] as String? ?? ''), + entries: entries, + ); + } +} + +/// 推荐榜中的一部短剧及其服务端排名。 +class CategoryRankingEntry { + const CategoryRankingEntry({required this.rank, required this.drama}); + + final int rank; + final Drama drama; + + factory CategoryRankingEntry.fromJson(Map json) { + final rawDrama = json['drama']; + return CategoryRankingEntry( + rank: (json['rank'] as num?)?.toInt() ?? 0, + drama: Drama.fromCardJson( + rawDrama is Map + ? rawDrama + : rawDrama is Map + ? rawDrama.cast() + : const {}, + ), + ); + } +} diff --git a/lib/core/data/models/content_category.dart b/lib/core/data/models/content_category.dart index af980a5..3f5a054 100644 --- a/lib/core/data/models/content_category.dart +++ b/lib/core/data/models/content_category.dart @@ -7,10 +7,19 @@ class ContentCategory { const ContentCategory({ required this.id, required this.name, + this.code = '', + this.layoutType = gridLayout, + this.revision = '1', this.locales = const {}, }); + static const String gridLayout = 'GRID'; + static const String rankingLayout = 'RANKING'; + final String id; + final String code; + final String layoutType; + final String revision; /// 中文名称,与 locales 里的 zh 相同。 final String name; @@ -20,9 +29,18 @@ class ContentCategory { factory ContentCategory.fromJson(Map json) { final locales = localeMap(json['locales']); final name = json['name'] as String? ?? locales['zh'] ?? ''; - return ContentCategory(id: (json['id'] ?? '').toString(), name: name, locales: locales); + return ContentCategory( + id: (json['id'] ?? '').toString(), + name: name, + code: json['code'] as String? ?? '', + layoutType: json['layoutType'] as String? ?? gridLayout, + revision: (json['revision'] ?? '1').toString(), + locales: locales, + ); } + bool get isRanking => layoutType == rankingLayout; + /// 按界面语言取名称。缺对应语言时回落到中文。 String localizedName(String languageCode) { return localizedText(languageCode, name, locales); diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart index 0c4c46c..bc2c9e7 100644 --- a/lib/core/data/remote/category_api.dart +++ b/lib/core/data/remote/category_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/content_category.dart'; +import '../models/category_ranking.dart'; import '../models/drama.dart'; import '../models/page_result.dart'; import 'api_client.dart'; @@ -30,11 +31,14 @@ class CategoryApi { }) async { final data = await _client.get( '/app/categories/$categoryId/dramas', - query: { - 'pageIndex': '$pageIndex', - 'pageSize': '$pageSize', - }, + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); return PageResult.parse(data, Drama.fromCardJson); } + + /// 推荐榜快照,服务端已确定内容顺序和连续排名。 + Future ranking(String categoryId) async { + final data = await _client.get('/app/categories/$categoryId/ranking'); + return CategoryRanking.fromJson(ResponseDecoders.asMap(data)); + } } diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index ecd6b87..026635b 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../../config/app_config.dart'; import '../models/content_category.dart'; +import '../models/category_ranking.dart'; import '../models/drama.dart'; import '../models/home_feed.dart'; import '../models/page_result.dart'; @@ -21,10 +22,15 @@ class CategoryController extends ChangeNotifier { String categoryId, { int pageIndex, int pageSize, - })? dramaPageLoader; + })? + dramaPageLoader; + + /// 生产环境推荐榜加载器,测试与本地数据模式可不注入。 + Future Function(String categoryId)? rankingLoader; List categories = const []; final Map _slots = {}; + final Map _rankingSlots = {}; HomeFeed feed = const HomeFeed(); bool loading = false; String? error; @@ -36,6 +42,29 @@ class CategoryController extends ChangeNotifier { return _slots.putIfAbsent(categoryId, CategoryDramaSlot.new); } + CategoryRankingSlot rankingSlotOf(String categoryId) { + return _rankingSlots.putIfAbsent(categoryId, CategoryRankingSlot.new); + } + + /// 打开推荐榜并获取当前服务端快照。 + Future openRanking(String categoryId) async { + final loader = rankingLoader; + if (loader == null) return; + final slot = rankingSlotOf(categoryId); + slot.loading = true; + slot.error = null; + _notify(); + try { + slot.ranking = await loader(categoryId); + } catch (exception) { + slot.error = exception; + if (kDebugMode) debugPrint('[category-ranking] $exception'); + } finally { + slot.loading = false; + _notify(); + } + } + /// 打开分类并加载第一页短剧。 Future openCategory(String categoryId) async { final loader = dramaPageLoader; @@ -126,8 +155,14 @@ class CategoryController extends ChangeNotifier { loading = false; _notify(); } - if (error == null && dramaPageLoader != null && categories.isNotEmpty) { - await openCategory(categories.first.id); + final hasRemoteLoader = dramaPageLoader != null || rankingLoader != null; + if (error == null && hasRemoteLoader && categories.isNotEmpty) { + final first = categories.first; + if (first.isRanking) { + await openRanking(first.id); + } else { + await openCategory(first.id); + } } } @@ -152,3 +187,10 @@ class CategoryDramaSlot { bool get hasMore => dramas.length < total; } + +/// 单个推荐榜的快照状态。 +class CategoryRankingSlot { + CategoryRanking? ranking; + Object? error; + bool loading = false; +} diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 3afbc7f..33c9640 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -22,6 +22,7 @@ import '../../../l10n/app_localizations.dart'; import 'home_category_dramas.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; +import 'home_ranking_dramas.dart'; const double _loadMoreThreshold = 240; @@ -175,6 +176,12 @@ class _HomeFeedGridState extends State { required CatalogController catalog, }) { final controller = context.read(); + if (category.isRanking) { + return HomeRankingDramas( + categoryId: category.categoryId, + onOpenDrama: widget.onOpenDrama, + ); + } if (controller.dramaPageLoader != null && category.categoryId.isNotEmpty) { return HomeCategoryDramas( lockedCategoryId: category.categoryId, @@ -320,6 +327,7 @@ class _HomeFeedGridState extends State { categoryName: category.name, categoryId: category.id, tab: HomePrimaryTab.categories, + layoutType: category.layoutType, ), ]; } diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 4110c6d..ec1892a 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -23,12 +23,16 @@ class HomeGridCategoryShortcut { required this.categoryName, required this.categoryId, required this.tab, + this.layoutType = 'GRID', }); final String label; final String categoryName; final String categoryId; final HomePrimaryTab tab; + final String layoutType; + + bool get isRanking => layoutType == 'RANKING'; } class HomeGridCallbacks { diff --git a/test/category/category_controller_test.dart b/test/category/category_controller_test.dart index b67a7eb..9bab631 100644 --- a/test/category/category_controller_test.dart +++ b/test/category/category_controller_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; +import 'package:fruitshorts/core/data/models/category_ranking.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; @@ -31,7 +32,13 @@ void main() { 'code': 0, 'message': 'ok', 'data': [ - {'id': id, 'name': 'Romance'}, + { + 'id': id, + 'name': '推荐榜', + 'code': 'RECOMMEND_RANKING', + 'layoutType': 'RANKING', + 'revision': '6', + }, ], }), ), @@ -44,11 +51,82 @@ void main() { final categories = await CategoryApi(client).list(); expect(categories.single.id, id); - expect(categories.single.name, 'Romance'); + expect(categories.single.name, '推荐榜'); + expect(categories.single.code, 'RECOMMEND_RANKING'); + expect(categories.single.isRanking, isTrue); + expect(categories.single.revision, '6'); expect(adapter.requests.single.uri.path, '/app/categories'); }, ); + test('should_parse_ranking_snapshot_without_reordering_entries', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'id': '7', + 'code': 'RECOMMEND_RANKING', + 'revision': '9', + 'list': [ + { + 'rank': 1, + 'drama': { + 'id': '22', + 'titleLocales': {'zh': '第二十二部'}, + }, + }, + { + 'rank': 2, + 'drama': { + 'id': '11', + 'titleLocales': {'zh': '第十一部'}, + }, + }, + ], + }, + }), + ), + ); + final api = CategoryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final ranking = await api.ranking('7'); + + expect(ranking.revision, '9'); + expect(ranking.entries.map((item) => item.rank), [1, 2]); + expect(ranking.entries.map((item) => item.drama.id), ['22', '11']); + expect(adapter.requests.single.uri.path, '/app/categories/7/ranking'); + }); + + test('should_load_ranking_when_first_category_uses_ranking_layout', () async { + final repository = ScriptedCategoryRepository( + categories: const [ + ContentCategory( + id: '7', + name: '推荐榜', + layoutType: ContentCategory.rankingLayout, + ), + ], + ); + final controller = CategoryController(repository); + controller.dramaPageLoader = (_, {pageIndex = 1, pageSize = 20}) async => + throw StateError('grid loader should not run'); + controller.rankingLoader = (_) async => CategoryRanking( + id: '7', + code: 'RECOMMEND_RANKING', + revision: '3', + entries: [CategoryRankingEntry(rank: 1, drama: _drama('rank-1'))], + ); + + await controller.load(); + + expect(controller.rankingSlotOf('7').ranking?.revision, '3'); + expect(controller.slotOf('7').pageIndex, 0); + }); + test('should_return_category_dramas_when_category_id_exists', () async { final repository = ScriptedCategoryRepository( categories: const [