feat(app): 搜索改为调用后端并渲染卡片结果
This commit is contained in:
1 parent
730e38622f
commit
ade0cc3566
13 files changed
+424
-20
No files matched your search
@@ -30,6 +30,7 @@ import 'core/data/repositories/favorite_repository.dart';
|
||||
import 'core/data/repositories/interaction_repository.dart';
|
||||
import 'core/data/repositories/notice_repository.dart';
|
||||
import 'core/data/repositories/playback_repository.dart';
|
||||
import 'core/data/repositories/search_repository.dart';
|
||||
import 'core/data/repositories/store_repository.dart';
|
||||
import 'core/data/repositories/watch_history_repository.dart';
|
||||
import 'core/data/services/auth_controller.dart';
|
||||
@@ -64,6 +65,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
this.accountRepository,
|
||||
this.commerceRepository,
|
||||
this.storeRepository,
|
||||
this.searchRepository,
|
||||
this.haptics = const FlutterHaptics(),
|
||||
});
|
||||
|
||||
@@ -82,6 +84,7 @@ class LumaDramaApp extends StatefulWidget {
|
||||
final AccountRepository? accountRepository;
|
||||
final CommerceRepository? commerceRepository;
|
||||
final StoreRepository? storeRepository;
|
||||
final SearchRepository? searchRepository;
|
||||
final Haptics haptics;
|
||||
|
||||
@override
|
||||
@@ -149,6 +152,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
_commerceRepository,
|
||||
);
|
||||
|
||||
late final SearchRepository _searchRepository =
|
||||
widget.searchRepository ?? HttpSearchRepository(CatalogApi(_apiClient));
|
||||
|
||||
late final StoreRepository _storeRepository =
|
||||
widget.storeRepository ?? HttpStoreRepository(CommerceApi(_apiClient));
|
||||
|
||||
@@ -223,6 +229,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
Provider<AccountRepository>.value(value: _accountRepository),
|
||||
Provider<CommerceRepository>.value(value: _commerceRepository),
|
||||
Provider<StoreRepository>.value(value: _storeRepository),
|
||||
Provider<SearchRepository>.value(value: _searchRepository),
|
||||
Provider<Haptics>.value(value: widget.haptics),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||
|
||||
@@ -65,6 +65,7 @@ class Drama {
|
||||
this.freeEpisodes = 0,
|
||||
this.isHot = false,
|
||||
this.viewCount = 0,
|
||||
this.language = '',
|
||||
this.detailLoaded = false,
|
||||
});
|
||||
|
||||
@@ -80,6 +81,9 @@ class Drama {
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
|
||||
/// 卡片语种(如 zh-CN / id),用于剧场语言筛选。
|
||||
final String language;
|
||||
|
||||
/// 详情接口是否已经取过完整剧集列表。
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||
final bool detailLoaded;
|
||||
@@ -104,6 +108,7 @@ class Drama {
|
||||
isHot: json['isHot'] == true,
|
||||
// viewCount 后端是 Long,按字符串下发(见 json_numbers.dart)
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
language: json['language'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +125,7 @@ class Drama {
|
||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
viewCount: jsonToInt(json['viewCount']),
|
||||
language: json['language'] as String? ?? '',
|
||||
detailLoaded: true,
|
||||
episodes: (json['episodes'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'drama.dart';
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 搜索分页结果:`GET /app/search` 的 `{total, list}`。
|
||||
class SearchResult {
|
||||
const SearchResult({this.total = 0, this.list = const []});
|
||||
|
||||
final int total;
|
||||
final List<Drama> list;
|
||||
|
||||
factory SearchResult.fromJson(Map<String, dynamic> json) {
|
||||
return SearchResult(
|
||||
total: jsonToInt(json['total']),
|
||||
list: (json['list'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/search_result.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 客户端目录接口,对应后端 `/app/**`。
|
||||
@@ -16,6 +17,23 @@ class CatalogApi {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final data = await _client.get(
|
||||
'/app/search',
|
||||
query: {
|
||||
'keyword': keyword,
|
||||
'pageIndex': '$pageIndex',
|
||||
'pageSize': '$pageSize',
|
||||
},
|
||||
);
|
||||
return SearchResult.fromJson(_asMap(data));
|
||||
}
|
||||
|
||||
Future<Drama> dramaDetail(String dramaId) async {
|
||||
return Drama.fromDetailJson(
|
||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
/// 搜索数据来源:只调用后端 `/app/search`,不在客户端做本地过滤。
|
||||
abstract interface class SearchRepository {
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
});
|
||||
}
|
||||
|
||||
class HttpSearchRepository implements SearchRepository {
|
||||
const HttpSearchRepository(this._api);
|
||||
|
||||
final CatalogApi _api;
|
||||
|
||||
@override
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
int pageIndex = 1,
|
||||
int pageSize = 20,
|
||||
}) => _api.search(keyword, pageIndex: pageIndex, pageSize: pageSize);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/search_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import 'discover_logic.dart';
|
||||
|
||||
@@ -8,5 +9,10 @@ class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(drama: drama, history: history);
|
||||
SearchRepository? search,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
searchRepository: search,
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/remote/api_exception.dart';
|
||||
import '../../core/data/repositories/search_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
enum DiscoverPageAction { home, my, watchlist, detail, resume }
|
||||
|
||||
class DiscoverLogic extends ChangeNotifier {
|
||||
DiscoverLogic({required this.history, required this.drama}) {
|
||||
DiscoverLogic({
|
||||
required this.history,
|
||||
required this.drama,
|
||||
this.searchRepository,
|
||||
}) {
|
||||
refreshPosition();
|
||||
}
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
final SearchRepository? searchRepository;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
WatchProgress? resumeRecord;
|
||||
@@ -22,6 +31,17 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
bool searchOpen = false;
|
||||
|
||||
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。
|
||||
Duration searchDebounce = const Duration(milliseconds: 350);
|
||||
static const int searchPageSize = 20;
|
||||
List<Drama> searchResults = const [];
|
||||
int searchTotal = 0;
|
||||
bool searchLoading = false;
|
||||
String? searchError;
|
||||
Timer? _searchTimer;
|
||||
String _lastRequestedQuery = '';
|
||||
int _searchRequest = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -51,20 +71,89 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
bool get hasSearchQuery => search.text.trim().isNotEmpty;
|
||||
|
||||
List<Drama> filtered(List<Drama> dramas) {
|
||||
final query = search.text.trim().toLowerCase();
|
||||
if (query.isEmpty) return dramas;
|
||||
return dramas.where((drama) {
|
||||
final haystack = [
|
||||
drama.title,
|
||||
drama.genre,
|
||||
drama.category,
|
||||
drama.synopsis,
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}).toList();
|
||||
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
|
||||
void onSearchChanged() {
|
||||
final query = search.text.trim();
|
||||
if (query.isEmpty) {
|
||||
_resetSearch();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
notify();
|
||||
_scheduleSearch(query);
|
||||
}
|
||||
|
||||
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
|
||||
List<Drama> visibleDramas(List<Drama> fallback) {
|
||||
if (!hasSearchQuery) return fallback;
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
Future<void> retrySearch() async {
|
||||
final query = search.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
await _runSearch(query);
|
||||
}
|
||||
|
||||
void _resetSearch() {
|
||||
_searchTimer?.cancel();
|
||||
_searchRequest++;
|
||||
searchResults = const [];
|
||||
searchTotal = 0;
|
||||
searchLoading = false;
|
||||
searchError = null;
|
||||
_lastRequestedQuery = '';
|
||||
}
|
||||
|
||||
void _scheduleSearch(String query) {
|
||||
final alreadyRequested = query == _lastRequestedQuery;
|
||||
if (alreadyRequested && (searchLoading || searchResults.isNotEmpty || searchError != null)) {
|
||||
return;
|
||||
}
|
||||
_lastRequestedQuery = query;
|
||||
_searchTimer?.cancel();
|
||||
if (searchDebounce == Duration.zero) {
|
||||
unawaited(_runSearch(query));
|
||||
return;
|
||||
}
|
||||
_searchTimer = Timer(searchDebounce, () => unawaited(_runSearch(query)));
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String query) async {
|
||||
final repository = searchRepository;
|
||||
if (repository == null) return;
|
||||
final request = ++_searchRequest;
|
||||
searchLoading = true;
|
||||
searchError = null;
|
||||
notify();
|
||||
try {
|
||||
final result = await repository.search(
|
||||
query,
|
||||
pageIndex: 1,
|
||||
pageSize: searchPageSize,
|
||||
);
|
||||
if (request != _searchRequest) return;
|
||||
searchResults = result.list;
|
||||
searchTotal = result.total;
|
||||
} on ApiException catch (exception) {
|
||||
_applySearchError(request, exception.message);
|
||||
} catch (_) {
|
||||
_applySearchError(request, 'searchFailed');
|
||||
} finally {
|
||||
if (request == _searchRequest) {
|
||||
searchLoading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applySearchError(int request, String message) {
|
||||
if (request != _searchRequest) return;
|
||||
searchError = message;
|
||||
searchResults = const [];
|
||||
searchTotal = 0;
|
||||
}
|
||||
|
||||
Drama? resumeDrama(List<Drama> dramas) {
|
||||
@@ -165,6 +254,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_searchTimer?.cancel();
|
||||
search.dispose();
|
||||
searchFocus.dispose();
|
||||
scroll.dispose();
|
||||
|
||||
@@ -8,8 +8,10 @@ import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/search_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
@@ -30,6 +32,7 @@ class DiscoverPage extends StatefulWidget {
|
||||
required this.duration,
|
||||
this.focusSearch = false,
|
||||
this.history,
|
||||
this.searchRepository,
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.searchRequest = 0,
|
||||
@@ -48,6 +51,7 @@ class DiscoverPage extends StatefulWidget {
|
||||
final Duration duration;
|
||||
final bool focusSearch;
|
||||
final WatchHistoryRepository? history;
|
||||
final SearchRepository? searchRepository;
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int searchRequest;
|
||||
@@ -74,6 +78,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
logic = DiscoverBinding().create(
|
||||
drama: widget.drama,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
search: widget.searchRepository ?? context.read<SearchRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) {
|
||||
logic.searchOpen = true;
|
||||
@@ -115,7 +120,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final categories = context.watch<CategoryController>();
|
||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||
final visible = logic.filtered(categories.visibleDramas);
|
||||
final visible = logic.visibleDramas(categories.visibleDramas);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
@@ -235,7 +240,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
l10n: l10n,
|
||||
categories: categories,
|
||||
dramas: visible,
|
||||
searching: logic.search.text.trim().isNotEmpty,
|
||||
searching: logic.hasSearchQuery,
|
||||
searchLoading: logic.searchLoading,
|
||||
searchError: logic.searchError,
|
||||
onRetrySearch: () => logic.retrySearch(),
|
||||
onOpen: (drama) => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
@@ -329,6 +337,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
|
||||
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
||||
textInputAction: TextInputAction.search,
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||
@@ -343,6 +352,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
color: AppColors.subtle,
|
||||
size: 17,
|
||||
),
|
||||
suffixIcon: logic.searchLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(11),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
|
||||
@@ -8,6 +8,9 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
required this.categories,
|
||||
required this.dramas,
|
||||
required this.searching,
|
||||
required this.searchLoading,
|
||||
required this.searchError,
|
||||
required this.onRetrySearch,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
@@ -15,6 +18,9 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
final CategoryController categories;
|
||||
final List<Drama> dramas;
|
||||
final bool searching;
|
||||
final bool searchLoading;
|
||||
final String? searchError;
|
||||
final VoidCallback onRetrySearch;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
@override
|
||||
@@ -37,6 +43,29 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (searching && searchLoading) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('discover-search-loading'),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (searching && searchError != null) {
|
||||
return _fill(
|
||||
EmptyState(
|
||||
key: const Key('discover-search-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
title: errorText(l10n, searchError, l10n.searchFailed),
|
||||
actionLabel: l10n.retry,
|
||||
actionKey: const Key('discover-search-retry'),
|
||||
onAction: onRetrySearch,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (dramas.isEmpty) {
|
||||
return _fill(
|
||||
EmptyState(
|
||||
|
||||
Reference in new issue
Block a user