From 730a96559bd6b96dc9f2ba4f7a34ee1758a88b49 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 18:17:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E5=A2=9E=E5=8A=A0=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=8A=A0=E8=BD=BD=E8=AF=8A=E6=96=AD=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E5=8F=B0=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 47 ++++++++++++-- .../data/services/catalog_controller.dart | 18 +++++- lib/core/data/services/catalog_retry.dart | 12 ++++ .../data/services/category_controller.dart | 9 ++- lib/core/widgets/catalog_debug_info.dart | 47 ++++++++++++++ lib/core/widgets/empty_state.dart | 6 ++ lib/pages/discover/discover_view.dart | 18 +++++- lib/pages/home/home_view.dart | 64 ++++++++++++++++++- test/discover/home_all_dramas_grid_test.dart | 3 + test/home/catalog_diagnostics_test.dart | 60 +++++++++++++++++ 10 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 lib/core/data/services/catalog_retry.dart create mode 100644 lib/core/widgets/catalog_debug_info.dart create mode 100644 test/home/catalog_diagnostics_test.dart diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 041d608..256a2d6 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -80,9 +80,17 @@ class AppConfig { Future Function(String baseUrl)? probe, String? fallbackBaseUrl, }) async { - final candidateProbe = probe ?? _probeBaseUrl; for (final candidate in _probeCandidates) { - if (await candidateProbe(candidate)) { + final result = probe == null + ? await _probeBaseUrl(candidate) + : await _runInjectedProbe(candidate, probe); + if (kDebugMode) { + final status = result.ok + ? 'ok' + : 'fail(${result.reason ?? 'request failed'})'; + debugPrint('[probe] $candidate -> $status'); + } + if (result.ok) { applyRuntimeBaseUrl(candidate); return; } @@ -90,25 +98,43 @@ class AppConfig { applyRuntimeBaseUrl(fallbackBaseUrl ?? defaultBaseUrl); } - static Future _probeBaseUrl(String baseUrl) async { + static Future<_ProbeResult> _runInjectedProbe( + String baseUrl, + Future Function(String baseUrl) probe, + ) async { + try { + return _ProbeResult(ok: await probe(baseUrl)); + } catch (error) { + return _ProbeResult(ok: false, reason: error.toString()); + } + } + + static Future<_ProbeResult> _probeBaseUrl(String baseUrl) async { final client = HttpClient()..connectionTimeout = _probeTimeout; try { return await _requestCategories( client, Uri.parse('$baseUrl/app/categories'), ).timeout(_probeTimeout); - } catch (_) { - return false; + } catch (error) { + return _ProbeResult(ok: false, reason: error.toString()); } finally { client.close(force: true); } } - static Future _requestCategories(HttpClient client, Uri uri) async { + static Future<_ProbeResult> _requestCategories( + HttpClient client, + Uri uri, + ) async { final request = await client.getUrl(uri); final response = await request.close(); await response.drain(); - return response.statusCode == 200; + final ok = response.statusCode == 200; + return _ProbeResult( + ok: ok, + reason: ok ? null : 'HTTP ${response.statusCode}', + ); } /// 后端返回的是相对路径(/files/...),这里补上服务地址。 @@ -118,3 +144,10 @@ class AppConfig { return '$apiBaseUrl$path'; } } + +class _ProbeResult { + const _ProbeResult({required this.ok, this.reason}); + + final bool ok; + final String? reason; +} diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index dfab65e..3766f2e 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; +import '../../config/app_config.dart'; import '../models/drama.dart'; import '../models/home_feed.dart'; import '../repositories/catalog_repository.dart'; @@ -22,6 +23,7 @@ class CatalogController extends ChangeNotifier { /// 第一次首页请求结束后为 true。之后的重试不再回到开屏。 bool settled = false; String? error; + String? debugError; List get dramas => feed.dramas; List get banners => feed.banners; @@ -30,13 +32,18 @@ class CatalogController extends ChangeNotifier { Future load() async { loading = true; error = null; + debugError = null; notifyListeners(); try { feed = await repository.fetchHomeFeed(); } on ApiException catch (exception) { error = exception.message; - } catch (_) { + debugError = exception.toString(); + _logFailure(exception); + } catch (exception) { error = 'catalogError'; + debugError = exception.toString(); + _logFailure(exception); } finally { loading = false; settled = true; @@ -52,11 +59,18 @@ class CatalogController extends ChangeNotifier { feed = await repository.fetchHomeFeed(); notifyListeners(); return true; - } catch (_) { + } catch (exception) { + _logFailure(exception); return false; } } + void _logFailure(Object error) { + if (kDebugMode) { + debugPrint('[catalog] base=${AppConfig.apiBaseUrl} error=$error'); + } + } + /// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。 Future requireDetail(Drama drama) async { if (drama.detailLoaded) return drama; diff --git a/lib/core/data/services/catalog_retry.dart b/lib/core/data/services/catalog_retry.dart new file mode 100644 index 0000000..df7b809 --- /dev/null +++ b/lib/core/data/services/catalog_retry.dart @@ -0,0 +1,12 @@ +// Author: fruit.com.cn + +import '../../config/app_config.dart'; + +/// 重新探测服务地址后再加载指定目录数据。 +Future retryCatalogLoad({ + required Future Function() reload, + Future Function()? probe, +}) async { + await (probe ?? AppConfig.probeAndApplyBaseUrl)(); + await reload(); +} diff --git a/lib/core/data/services/category_controller.dart b/lib/core/data/services/category_controller.dart index 026f17f..d79702b 100644 --- a/lib/core/data/services/category_controller.dart +++ b/lib/core/data/services/category_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; +import '../../config/app_config.dart'; import '../models/content_category.dart'; import '../models/drama.dart'; import '../models/home_feed.dart'; @@ -17,6 +18,7 @@ class CategoryController extends ChangeNotifier { HomeFeed feed = const HomeFeed(); bool loading = false; String? error; + String? debugError; bool _disposed = false; /// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。 @@ -32,6 +34,7 @@ class CategoryController extends ChangeNotifier { Future load() async { loading = true; error = null; + debugError = null; _notify(); try { final results = await Future.wait([ @@ -40,8 +43,12 @@ class CategoryController extends ChangeNotifier { ]); categories = results[0] as List; feed = results[1] as HomeFeed; - } catch (_) { + } catch (exception) { error = 'categoriesError'; + debugError = exception.toString(); + if (kDebugMode) { + debugPrint('[catalog] base=${AppConfig.apiBaseUrl} error=$exception'); + } } finally { loading = false; _notify(); diff --git a/lib/core/widgets/catalog_debug_info.dart b/lib/core/widgets/catalog_debug_info.dart new file mode 100644 index 0000000..479da40 --- /dev/null +++ b/lib/core/widgets/catalog_debug_info.dart @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; + +const TextStyle _debugStyle = TextStyle( + color: AppColors.subtle, + fontSize: 12, + height: 1.35, +); + +/// 仅 debug 展示当前服务地址与目录请求的原始错误。 +class CatalogDebugInfo extends StatelessWidget { + const CatalogDebugInfo({super.key, required this.error}); + + final String? error; + + @override + Widget build(BuildContext context) { + if (!kDebugMode) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '服务地址:${AppConfig.apiBaseUrl}', + key: const Key('catalog-debug-endpoint'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: _debugStyle, + ), + const SizedBox(height: 2), + Text( + '错误:${error ?? 'unknown'}', + key: const Key('catalog-debug-error'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: _debugStyle, + ), + ], + ); + } +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index da31b46..75b3ca3 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -25,6 +25,7 @@ class EmptyState extends StatelessWidget { this.actionLabel, this.actionKey, this.onAction, + this.footer, this.compact = false, }); @@ -34,6 +35,7 @@ class EmptyState extends StatelessWidget { final String? actionLabel; final Key? actionKey; final VoidCallback? onAction; + final Widget? footer; final bool compact; @override @@ -65,6 +67,7 @@ class EmptyState extends StatelessWidget { actionLabel: hasAction ? actionLabel : null, actionKey: actionKey, onAction: hasAction ? onAction : null, + footer: footer, ), ], ), @@ -101,6 +104,7 @@ class _EmptyCopy extends StatelessWidget { required this.actionLabel, required this.actionKey, required this.onAction, + required this.footer, }); final String title; @@ -108,6 +112,7 @@ class _EmptyCopy extends StatelessWidget { final String? actionLabel; final Key? actionKey; final VoidCallback? onAction; + final Widget? footer; @override Widget build(BuildContext context) { @@ -162,6 +167,7 @@ class _EmptyCopy extends StatelessWidget { ), ), ], + if (footer != null) ...[const SizedBox(height: 12), footer!], ], ); } diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 93e8b89..d3977a4 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -2,12 +2,14 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/search_repository.dart'; +import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; @@ -15,6 +17,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../filter/filter_page.dart'; @@ -253,7 +256,10 @@ class _DiscoverPageState extends State { title: errorText(l10n, error, l10n.categoriesError), subtitle: l10n.emptyHint, actionLabel: l10n.retry, - onAction: categories.load, + onAction: () => unawaited(_retryCategories(categories)), + footer: kDebugMode + ? CatalogDebugInfo(error: categories.debugError ?? error) + : null, ), ), ]; @@ -503,8 +509,14 @@ class _DiscoverPageState extends State { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } - Widget _block(Widget child) => - SizedBox(height: 280, child: Center(child: child)); + Widget _block(Widget child) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 280), + child: Center(child: child), + ); + + Future _retryCategories(CategoryController categories) { + return retryCatalogLoad(reload: categories.load); + } void _act(DiscoverPageAction action) { logic.act( diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index c174448..90ad457 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -2,15 +2,20 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/catalog_retry.dart'; +import '../../core/data/services/category_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; @@ -23,9 +28,57 @@ import '../mine/mine_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State with WidgetsBindingObserver { + bool _resumeRetryInFlight = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed || _resumeRetryInFlight) return; + final catalog = context.read(); + final categories = context.read(); + if (catalog.error == null && categories.error == null) return; + unawaited(_retryAfterResume(catalog, categories)); + } + + Future _retryAfterResume( + CatalogController catalog, + CategoryController categories, + ) async { + _resumeRetryInFlight = true; + try { + await AppConfig.probeAndApplyBaseUrl(); + await Future.wait([ + if (catalog.error != null) catalog.load(), + if (categories.error != null) categories.load(), + ]); + } finally { + _resumeRetryInFlight = false; + } + } + + Future _retryHome(CatalogController catalog) { + return retryCatalogLoad(reload: catalog.load); + } + @override Widget build(BuildContext context) { final catalog = context.watch(); @@ -51,7 +104,10 @@ class HomePage extends StatelessWidget { if (catalog.error != null) { return Scaffold( backgroundColor: Colors.transparent, - body: HomeFailureView(onRetry: catalog.load), + body: HomeFailureView( + error: catalog.debugError ?? catalog.error, + onRetry: () => unawaited(_retryHome(catalog)), + ), ); } if (catalog.dramas.isEmpty) { @@ -78,8 +134,9 @@ class HomePage extends StatelessWidget { /// 首页目录加载失败与重试入口。 class HomeFailureView extends StatelessWidget { - const HomeFailureView({super.key, required this.onRetry}); + const HomeFailureView({super.key, this.error, required this.onRetry}); + final String? error; final VoidCallback onRetry; @override @@ -90,6 +147,7 @@ class HomeFailureView extends StatelessWidget { title: l10n.catalogError, actionLabel: l10n.retry, onAction: onRetry, + footer: kDebugMode ? CatalogDebugInfo(error: error) : null, ); } } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 16bb0bf..d9d72b9 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -135,6 +135,9 @@ void main() { expect(find.byKey(const Key('home-categories-error')), findsOneWidget); expect(find.text('Retry'), findsOneWidget); + expect(find.byKey(const Key('catalog-debug-endpoint')), findsOneWidget); + expect(find.byKey(const Key('catalog-debug-error')), findsOneWidget); + expect(find.textContaining('discover feed unavailable'), findsOneWidget); }); testWidgets('should_show_search_results_when_text_changes', (tester) async { diff --git a/test/home/catalog_diagnostics_test.dart b/test/home/catalog_diagnostics_test.dart new file mode 100644 index 0000000..fffcbbf --- /dev/null +++ b/test/home/catalog_diagnostics_test.dart @@ -0,0 +1,60 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/data/services/catalog_retry.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_view.dart'; + +void main() { + tearDown(AppConfig.clearRuntimeBaseUrl); + + testWidgets('should_show_endpoint_and_raw_error_in_debug_failure', ( + tester, + ) async { + AppConfig.applyRuntimeBaseUrl('http://probe.test:8081'); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: HomeFailureView( + error: 'SocketException: Connection refused', + onRetry: () {}, + ), + ), + ), + ); + + final endpoint = tester.widget( + find.byKey(const Key('catalog-debug-endpoint')), + ); + expect(endpoint.data, '服务地址:http://probe.test:8081'); + expect(endpoint.style?.fontSize, 12); + expect(endpoint.style?.color, AppColors.subtle); + expect(endpoint.maxLines, 2); + + final error = tester.widget( + find.byKey(const Key('catalog-debug-error')), + ); + expect(error.data, '错误:SocketException: Connection refused'); + expect(error.style?.fontSize, 12); + expect(error.style?.color, AppColors.subtle); + expect(error.maxLines, 2); + }); + + test('should_probe_before_reloading_catalog', () async { + final steps = []; + + await retryCatalogLoad( + probe: () async => steps.add('probe'), + reload: () async => steps.add('load'), + ); + + expect(steps, ['probe', 'load']); + }); +}