diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json index 05a0836..8550e91 100644 --- a/assets/l10n/intl_ar.json +++ b/assets/l10n/intl_ar.json @@ -651,6 +651,8 @@ "pleaseGetOnTheMicFirst": "يرجى الصعود إلى الميكروفون أولاً.", "welcomeMessage": "مرحبًا بك في تطبيقنا، {name}!", "operationFail": "فشلت العملية.", + "enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.", + "voiceConnectionFailedRetry": "فشل الاتصال الصوتي. يرجى المحاولة مرة أخرى.", "doYouWantToKeepTheDraft": "هل تريد الاحتفاظ بالمسودة؟", "duration2": "المدة:{1}" } diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json index a3adaaf..5e07503 100644 --- a/assets/l10n/intl_bn.json +++ b/assets/l10n/intl_bn.json @@ -268,6 +268,8 @@ "dice": "ডাইস", "rps": "পাথর-কাগজ-কাঁচি", "operationFail": "অপারেশন ব্যর্থ হয়েছে।", + "enterRoomFailedRetry": "রুমে প্রবেশ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", + "voiceConnectionFailedRetry": "ভয়েস সংযোগ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "likedYourComment": "আপনার মন্তব্য পছন্দ করেছেন।", "doYouWantToKeepTheDraft": "আপনি কি ড্রাফট রাখতে চান?", "operationsAreTooFrequent": "অপারেশন খুব ঘন ঘন", diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json index 19fa137..17d61e3 100644 --- a/assets/l10n/intl_en.json +++ b/assets/l10n/intl_en.json @@ -239,6 +239,8 @@ "dice": "Dice", "rps": "RPS", "operationFail": "The operation failed.", + "enterRoomFailedRetry": "Failed to enter the room. Please try again.", + "voiceConnectionFailedRetry": "Voice connection failed. Please try again.", "likedYourComment": "Liked your comment.", "doYouWantToKeepTheDraft": "Do you want to keep the draft?", "operationsAreTooFrequent": "Operations are too frequent", diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json index 20715a7..c5ff04f 100644 --- a/assets/l10n/intl_tr.json +++ b/assets/l10n/intl_tr.json @@ -237,8 +237,10 @@ "win": "Kazanan", "dice": "Zar", "rps": "Taş-Kağıt-Makas", - "operationFail": "İşlem başarısız oldu.", - "likedYourComment": "Yorumunuzu beğendi.", + "operationFail": "İşlem başarısız oldu.", + "enterRoomFailedRetry": "Odaya girilemedi. Lütfen tekrar deneyin.", + "voiceConnectionFailedRetry": "Ses bağlantısı başarısız oldu. Lütfen tekrar deneyin.", + "likedYourComment": "Yorumunuzu beğendi.", "doYouWantToKeepTheDraft": "Taslağı saklamak ister misiniz?", "operationsAreTooFrequent": "İşlemler çok sık", "luckNumber": "Şans Numarası", diff --git a/lib/app_localizations.dart b/lib/app_localizations.dart index 143d872..67a39a7 100644 --- a/lib/app_localizations.dart +++ b/lib/app_localizations.dart @@ -239,6 +239,20 @@ class SCAppLocalizations { String get operationFail => translate('operationFail'); + String get enterRoomFailedRetry { + final value = translate('enterRoomFailedRetry'); + return value == 'enterRoomFailedRetry' + ? 'Failed to enter the room. Please try again.' + : value; + } + + String get voiceConnectionFailedRetry { + final value = translate('voiceConnectionFailedRetry'); + return value == 'voiceConnectionFailedRetry' + ? 'Voice connection failed. Please try again.' + : value; + } + String get forMoreRewardsPleaseCheckTheTaskCenter => translate('forMoreRewardsPleaseCheckTheTaskCenter'); diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 36aeae9..826c8a5 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -1,7 +1,10 @@ import 'dart:math' as math; import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:yumi/app_localizations.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/ui_kit/components/sc_compontent.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; @@ -60,6 +63,7 @@ class _VoiceRoomPageState extends State RoomGiftSeatFlightController(); final Map _luckyGiftComboSessions = {}; + int _shownRoomStartupFailureToken = 0; @override void initState() { @@ -154,6 +158,89 @@ class _VoiceRoomPageState extends State SCGiftVapSvgaManager().stopPlayback(); } + Widget _buildRoomStartupLayer() { + return Selector( + selector: + (context, provider) => _RoomStartupSnapshot( + status: provider.roomStartupStatus, + failureType: provider.roomStartupFailureType, + failureToken: provider.roomStartupFailureToken, + ), + builder: (context, snapshot, child) { + if (snapshot.status == RoomStartupStatus.failed) { + _showRoomStartupFailureDialog(snapshot); + return const SizedBox.shrink(); + } + if (snapshot.status != RoomStartupStatus.loading) { + return const SizedBox.shrink(); + } + return Positioned.fill( + child: AbsorbPointer( + child: Container( + color: Colors.black.withValues(alpha: 0.18), + alignment: Alignment.center, + child: Container( + width: 55.w, + height: 55.w, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(8.w), + ), + alignment: Alignment.center, + child: const CupertinoActivityIndicator(color: Colors.white24), + ), + ), + ), + ); + }, + ); + } + + void _showRoomStartupFailureDialog(_RoomStartupSnapshot snapshot) { + if (_shownRoomStartupFailureToken == snapshot.failureToken) { + return; + } + _shownRoomStartupFailureToken = snapshot.failureToken; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + SmartDialog.dismiss(tag: "roomStartupFailureDialog"); + SmartDialog.show( + tag: "roomStartupFailureDialog", + alignment: Alignment.center, + animationType: SmartAnimationType.fade, + backType: SmartBackType.block, + clickMaskDismiss: false, + builder: (_) { + return _RoomStartupFailureDialog( + title: SCAppLocalizations.of(context)!.tips, + message: _roomStartupFailureMessage(snapshot.failureType), + btnText: SCAppLocalizations.of(context)!.confirm, + onEnsure: () { + SmartDialog.dismiss(tag: "roomStartupFailureDialog"); + context.read().confirmRoomStartupFailureAndLeave( + navigationContext: context, + ); + }, + ); + }, + ); + }); + } + + String _roomStartupFailureMessage(RoomStartupFailureType failureType) { + switch (failureType) { + case RoomStartupFailureType.im: + return SCAppLocalizations.of(context)!.enterRoomFailedRetry; + case RoomStartupFailureType.rtc: + return SCAppLocalizations.of(context)!.voiceConnectionFailedRetry; + case RoomStartupFailureType.entry: + case RoomStartupFailureType.none: + return SCAppLocalizations.of(context)!.operationFail; + } + } + String? _resolveRoomThemeBackground(JoinRoomRes? room) { final roomTheme = room?.roomProps?.roomTheme; final themeBack = roomTheme?.themeBack ?? ""; @@ -172,6 +259,11 @@ class _VoiceRoomPageState extends State canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) { if (!didPop) { + final rtcProvider = context.read(); + if (rtcProvider.roomStartupStatus == RoomStartupStatus.loading || + rtcProvider.roomStartupStatus == RoomStartupStatus.failed) { + return; + } _suspendRoomVisualEffects(); SCFloatIchart().show(); SCNavigatorUtils.goBack(context); @@ -231,6 +323,7 @@ class _VoiceRoomPageState extends State ), ], ), + _buildRoomStartupLayer(), // _buildPlayViews(), ///幸运礼物中奖动画 LuckGiftNomorAnimWidget(), @@ -727,3 +820,110 @@ class _LuckyGiftComboSession { clearQueueTimer?.cancel(); } } + +class _RoomStartupSnapshot { + const _RoomStartupSnapshot({ + required this.status, + required this.failureType, + required this.failureToken, + }); + + final RoomStartupStatus status; + final RoomStartupFailureType failureType; + final int failureToken; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _RoomStartupSnapshot && + other.status == status && + other.failureType == failureType && + other.failureToken == failureToken; + } + + @override + int get hashCode => Object.hash(status, failureType, failureToken); +} + +class _RoomStartupFailureDialog extends StatelessWidget { + const _RoomStartupFailureDialog({ + required this.title, + required this.message, + required this.btnText, + required this.onEnsure, + }); + + final String title; + final String message; + final String btnText; + final VoidCallback onEnsure; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + constraints: BoxConstraints(maxWidth: 320.w), + margin: EdgeInsets.symmetric(horizontal: 22.w), + padding: EdgeInsets.fromLTRB(18.w, 18.w, 18.w, 30.w), + decoration: BoxDecoration( + color: const Color(0xff09372E), + borderRadius: BorderRadius.circular(12.w), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + SizedBox(height: 14.w), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + height: 1.25, + decoration: TextDecoration.none, + ), + ), + SizedBox(height: 18.w), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onEnsure, + child: Container( + width: 160.w, + height: 38.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xff18F2B1), + borderRadius: BorderRadius.circular(12.w), + ), + child: Text( + btnText, + style: TextStyle( + color: Colors.black, + fontSize: 14.sp, + fontWeight: FontWeight.w700, + decoration: TextDecoration.none, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/room_game/bridge/baishun_js_bridge.dart b/lib/modules/room_game/bridge/baishun_js_bridge.dart index ad15a0f..e42efa7 100644 --- a/lib/modules/room_game/bridge/baishun_js_bridge.dart +++ b/lib/modules/room_game/bridge/baishun_js_bridge.dart @@ -388,8 +388,11 @@ class BaishunJsBridge { static String buildConfigScript( BaishunBridgeConfigModel config, { String? jsCallbackPath, + Map viewportPayload = const {}, }) { - final payload = jsonEncode(config.toJson()); + final payload = jsonEncode( + _mergeViewportPayload(config.toJson(), viewportPayload), + ); final encodedCallbackPath = jsonEncode(jsCallbackPath?.trim() ?? ''); return ''' (function() { @@ -437,8 +440,15 @@ class BaishunJsBridge { } window.__baishunLastConfig = config; window.baishunBridgeConfig = config; + const viewport = config.viewport || config.nativeViewport || {}; + window.__baishunViewport = viewport; + window.baishunViewport = viewport; window.NativeBridge = window.NativeBridge || {}; window.NativeBridge.config = config; + window.NativeBridge.viewport = viewport; + window.NativeBridge.getViewport = function() { + return viewport; + }; const callbackPath = explicitCallbackPath || window.__baishunLastJsCallback || ''; let callbackInvoked = false; if (explicitCallbackPath) { @@ -455,6 +465,7 @@ class BaishunJsBridge { gsp: config.gsp, code: maskText(config.code), codeLength: config.code ? String(config.code).length : 0, + viewport: config.viewport || config.nativeViewport || null, callbackPath: callbackPath }); } @@ -486,6 +497,30 @@ class BaishunJsBridge { '''; } + static Map _mergeViewportPayload( + Map payload, + Map viewport, + ) { + if (viewport.isEmpty) { + return payload; + } + return { + ...payload, + 'viewport': viewport, + 'nativeViewport': viewport, + 'containerWidth': viewport['containerWidth'], + 'containerHeight': viewport['containerHeight'], + 'visibleHeight': viewport['visibleHeight'], + 'webViewHeight': viewport['webViewHeight'], + 'dialogHeight': viewport['dialogHeight'], + 'safeAreaTop': viewport['safeAreaTop'], + 'safeAreaBottom': viewport['safeAreaBottom'], + 'devicePixelRatio': viewport['devicePixelRatio'], + 'screenMode': viewport['screenMode'], + 'isCompactViewport': viewport['isCompactViewport'], + }; + } + static String buildWalletUpdateScript({Map? payload}) { final safePayload = jsonEncode( payload ?? diff --git a/lib/modules/room_game/bridge/leader_js_bridge.dart b/lib/modules/room_game/bridge/leader_js_bridge.dart index 23effc1..943d518 100644 --- a/lib/modules/room_game/bridge/leader_js_bridge.dart +++ b/lib/modules/room_game/bridge/leader_js_bridge.dart @@ -172,6 +172,14 @@ class LeaderJsBridge { normalizedPayload.entry && typeof normalizedPayload.entry === 'object' ? normalizedPayload.entry : {}; + const viewport = + normalizedPayload.viewport && typeof normalizedPayload.viewport === 'object' + ? normalizedPayload.viewport + : ( + launchConfig.viewport && typeof launchConfig.viewport === 'object' + ? launchConfig.viewport + : {} + ); window.__leaderLaunchPayload = normalizedPayload; window.__leaderLaunchPayloadText = payloadText; window.leaderLaunchPayload = normalizedPayload; @@ -179,14 +187,21 @@ class LeaderJsBridge { window.leaderLaunchConfig = launchConfig; window.__leaderLaunchEntry = entry; window.leaderLaunchEntry = entry; + window.__leaderViewport = viewport; + window.leaderViewport = viewport; window.NativeBridge = window.NativeBridge || {}; window.NativeBridge.config = launchConfig; window.NativeBridge.launchConfig = launchConfig; window.NativeBridge.entry = entry; + window.NativeBridge.viewport = viewport; window.NativeBridge.getConfig = function(callback) { invokeCallback(callback, launchConfig); return launchConfig; }; + window.NativeBridge.getViewport = function(callback) { + invokeCallback(callback, viewport); + return viewport; + }; window.NativeBridge.getLaunchPayload = function(callback) { invokeCallback(callback, normalizedPayload); return normalizedPayload; @@ -207,7 +222,8 @@ class LeaderJsBridge { ? String(launchConfig.code).slice(0, 6) + '***' : '', codeLength: launchConfig.code ? String(launchConfig.code).length : 0, - entryUrl: entry.entryUrl || '' + entryUrl: entry.entryUrl || '', + viewport: viewport }); } } diff --git a/lib/modules/room_game/utils/room_game_viewport.dart b/lib/modules/room_game/utils/room_game_viewport.dart new file mode 100644 index 0000000..73aa5e3 --- /dev/null +++ b/lib/modules/room_game/utils/room_game_viewport.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +const double roomGameDesignWidth = 750; + +double resolveRoomGameAvailableHeight( + BuildContext context, { + double reservedTop = 12, +}) { + final mediaQuery = MediaQuery.maybeOf(context); + if (mediaQuery == null) { + return 0; + } + + final bottomInset = + mediaQuery.viewInsets.bottom > mediaQuery.padding.bottom + ? mediaQuery.viewInsets.bottom + : mediaQuery.padding.bottom; + final availableHeight = + mediaQuery.size.height - + mediaQuery.padding.top - + bottomInset - + reservedTop; + if (!availableHeight.isFinite || availableHeight <= 0) { + return mediaQuery.size.height; + } + return availableHeight.clamp(0.0, mediaQuery.size.height).toDouble(); +} + +double clampRoomGameHeight( + double value, + double maxHeight, { + double minHeight = 0, +}) { + if (!value.isFinite || value <= 0) { + return maxHeight > 0 ? maxHeight : 0; + } + if (!maxHeight.isFinite || maxHeight <= 0) { + return value; + } + if (minHeight > maxHeight) { + return maxHeight; + } + return value.clamp(minHeight, maxHeight).toDouble(); +} + +String resolveRoomGameScreenMode( + Map launchParams, { + required bool fullScreen, +}) { + final candidates = [ + launchParams['screenMode']?.toString() ?? '', + launchParams['screen_mode']?.toString() ?? '', + launchParams['displayMode']?.toString() ?? '', + launchParams['gameScreenMode']?.toString() ?? '', + ]; + for (final candidate in candidates) { + final trimmed = candidate.trim(); + if (trimmed.isNotEmpty) { + return trimmed.toLowerCase(); + } + } + return fullScreen ? 'full' : 'half'; +} + +Map buildRoomGameViewportPayload( + BuildContext context, { + required double containerHeight, + double? dialogHeight, + double headerHeight = 0, + double topCrop = 0, + required String screenMode, + required int safeHeight, + double designWidth = roomGameDesignWidth, + double? designHeight, + double? actualWebViewHeight, +}) { + final mediaQuery = MediaQuery.maybeOf(context); + final size = mediaQuery?.size ?? Size.zero; + final padding = mediaQuery?.padding ?? EdgeInsets.zero; + final viewInsets = mediaQuery?.viewInsets ?? EdgeInsets.zero; + final targetDesignHeight = + designHeight ?? (safeHeight > 0 ? safeHeight.toDouble() : null); + final expectedHeight = + targetDesignHeight != null && size.width > 0 && designWidth > 0 + ? size.width * targetDesignHeight / designWidth + : null; + final availableHeight = resolveRoomGameAvailableHeight(context); + + return { + 'containerWidth': _roundMetric(size.width), + 'containerHeight': _roundMetric(containerHeight), + 'visibleHeight': _roundMetric(containerHeight), + 'webViewHeight': _roundMetric(actualWebViewHeight ?? containerHeight), + 'dialogHeight': _roundMetric( + dialogHeight ?? containerHeight + headerHeight, + ), + 'screenHeight': _roundMetric(size.height), + 'headerHeight': _roundMetric(headerHeight), + 'topCrop': _roundMetric(topCrop), + 'safeAreaTop': _roundMetric(padding.top), + 'safeAreaBottom': _roundMetric(padding.bottom), + 'viewInsetBottom': _roundMetric(viewInsets.bottom), + 'availableHeight': _roundMetric(availableHeight), + 'devicePixelRatio': _roundMetric(mediaQuery?.devicePixelRatio ?? 1), + 'screenMode': screenMode, + 'isCompactViewport': + expectedHeight != null ? containerHeight + 1 < expectedHeight : false, + 'designWidth': _roundMetric(designWidth), + if (targetDesignHeight != null) + 'designHeight': _roundMetric(targetDesignHeight), + 'safeHeight': safeHeight, + }; +} + +Map mergeRoomGameViewportPayload( + Map payload, + Map viewport, +) { + if (viewport.isEmpty) { + return payload; + } + + return { + ...payload, + 'viewport': viewport, + 'nativeViewport': viewport, + 'containerWidth': viewport['containerWidth'], + 'containerHeight': viewport['containerHeight'], + 'visibleHeight': viewport['visibleHeight'], + 'webViewHeight': viewport['webViewHeight'], + 'dialogHeight': viewport['dialogHeight'], + 'safeAreaTop': viewport['safeAreaTop'], + 'safeAreaBottom': viewport['safeAreaBottom'], + 'devicePixelRatio': viewport['devicePixelRatio'], + 'screenMode': viewport['screenMode'], + 'isCompactViewport': viewport['isCompactViewport'], + }; +} + +double _roundMetric(double value) { + if (!value.isFinite) { + return 0; + } + return double.parse(value.toStringAsFixed(2)); +} diff --git a/lib/modules/room_game/views/baishun_game_page.dart b/lib/modules/room_game/views/baishun_game_page.dart index 76087f6..a26a401 100644 --- a/lib/modules/room_game/views/baishun_game_page.dart +++ b/lib/modules/room_game/views/baishun_game_page.dart @@ -11,6 +11,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/modules/room_game/bridge/baishun_js_bridge.dart'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/utils/room_game_viewport.dart'; import 'package:yumi/modules/room_game/views/baishun_loading_view.dart'; import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -33,7 +34,6 @@ class BaishunGamePage extends StatefulWidget { class _BaishunGamePageState extends State { static const String _logPrefix = '[BaishunGame]'; - static const double _designWidth = 750; final RoomGameRepository _repository = RoomGameRepository(); final Set> _webGestureRecognizers = @@ -260,15 +260,17 @@ class _BaishunGamePageState extends State { _hasDeliveredLaunchConfig = true; final rawConfig = widget.launchModel.bridgeConfig; final config = _buildEffectiveBridgeConfig(rawConfig); + final viewport = _buildViewportPayload(); _log( 'send_config jsCallback=${jsCallbackPath ?? ''} ' - 'force=$force config=${_stringifyForLog(_buildConfigSummary(config, rawConfig: rawConfig))}', + 'force=$force config=${_stringifyForLog({..._buildConfigSummary(config, rawConfig: rawConfig), 'viewport': viewport})}', ); try { await _controller.runJavaScript( BaishunJsBridge.buildConfigScript( config, jsCallbackPath: jsCallbackPath, + viewportPayload: viewport, ), ); } catch (error) { @@ -609,22 +611,45 @@ class _BaishunGamePageState extends State { return 0; } + String _resolveScreenMode() { + return resolveRoomGameScreenMode( + widget.game.launchParams, + fullScreen: widget.game.fullScreen, + ); + } + double _calculateWebViewHeight(BuildContext context) { - final screenSize = MediaQuery.sizeOf(context); - final safePadding = MediaQuery.paddingOf(context); + final mediaQuery = MediaQuery.maybeOf(context); + final screenSize = mediaQuery?.size ?? Size.zero; final safeHeight = _resolveSafeHeight(); final fallbackHeight = screenSize.height * 0.5; + final maxHeight = resolveRoomGameAvailableHeight( + context, + reservedTop: 12.w, + ); if (safeHeight <= 0 || screenSize.width <= 0) { - return fallbackHeight; + return clampRoomGameHeight(fallbackHeight, maxHeight); } - final ratio = _designWidth / safeHeight; + final ratio = roomGameDesignWidth / safeHeight; final webViewHeight = screenSize.width / ratio; - final maxHeight = screenSize.height - safePadding.top - 12.w; - if (maxHeight <= 0) { - return fallbackHeight; - } - return webViewHeight.clamp(0.0, maxHeight).toDouble(); + return clampRoomGameHeight(webViewHeight, maxHeight); + } + + Map _buildViewportPayload() { + final topCrop = 28.w; + final webViewHeight = _calculateWebViewHeight(context); + final safeHeight = _resolveSafeHeight(); + return buildRoomGameViewportPayload( + context, + containerHeight: webViewHeight, + dialogHeight: webViewHeight, + topCrop: topCrop, + screenMode: _resolveScreenMode(), + safeHeight: safeHeight, + designHeight: safeHeight > 0 ? safeHeight.toDouble() : null, + actualWebViewHeight: webViewHeight + topCrop, + ); } @override diff --git a/lib/modules/room_game/views/leader_game_page.dart b/lib/modules/room_game/views/leader_game_page.dart index 180f75b..48fab93 100644 --- a/lib/modules/room_game/views/leader_game_page.dart +++ b/lib/modules/room_game/views/leader_game_page.dart @@ -10,6 +10,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/modules/room_game/bridge/leader_js_bridge.dart'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/utils/room_game_viewport.dart'; import 'package:yumi/modules/room_game/views/baishun_loading_view.dart'; import 'package:yumi/modules/wallet/wallet_route.dart'; import 'package:yumi/ui_kit/components/sc_tts.dart'; @@ -32,7 +33,6 @@ class LeaderGamePage extends StatefulWidget { class _LeaderGamePageState extends State { static const String _logPrefix = '[LeaderGame]'; - static const double _designWidth = 750; static const double _hdDesignHeight = 1044; final RoomGameRepository _repository = RoomGameRepository(); @@ -215,7 +215,9 @@ class _LeaderGamePageState extends State { } try { await _controller.runJavaScript( - LeaderJsBridge.bootstrapScript(launchPayload: _buildLaunchPayload()), + LeaderJsBridge.bootstrapScript( + launchPayload: _buildLaunchPayload(viewport: _buildViewportPayload()), + ), ); } catch (error) { _log( @@ -328,23 +330,10 @@ class _LeaderGamePageState extends State { } String _resolveScreenMode() { - final launchParams = widget.game.launchParams; - final candidates = [ - launchParams['screenMode']?.toString() ?? '', - launchParams['screen_mode']?.toString() ?? '', - launchParams['displayMode']?.toString() ?? '', - launchParams['gameScreenMode']?.toString() ?? '', - ]; - for (final candidate in candidates) { - final trimmed = candidate.trim(); - if (trimmed.isNotEmpty) { - return trimmed.toLowerCase(); - } - } - if (widget.game.fullScreen) { - return 'full'; - } - return 'half'; + return resolveRoomGameScreenMode( + widget.game.launchParams, + fullScreen: widget.game.fullScreen, + ); } int _resolveSafeHeight() { @@ -357,33 +346,38 @@ class _LeaderGamePageState extends State { return 0; } - double _calculateBodyHeight(BuildContext context) { - final screenSize = MediaQuery.sizeOf(context); - final safePadding = MediaQuery.paddingOf(context); + double _calculateBodyHeight(BuildContext context, double headerHeight) { + final mediaQuery = MediaQuery.maybeOf(context); + final screenSize = mediaQuery?.size ?? Size.zero; final screenMode = _resolveScreenMode(); final safeHeight = _resolveSafeHeight(); final fallback = screenSize.width; - final maxHeight = screenSize.height - safePadding.top - 24.w; - if (maxHeight <= 0) { - return fallback; + final maxDialogHeight = resolveRoomGameAvailableHeight( + context, + reservedTop: 24.w, + ); + final maxBodyHeight = maxDialogHeight - headerHeight; + if (maxBodyHeight <= 0) { + return 0; } if (screenMode == 'full') { - return maxHeight; + return maxBodyHeight; } if (safeHeight > 0 && screenSize.width > 0) { - final ratio = _designWidth / safeHeight; + final ratio = roomGameDesignWidth / safeHeight; final bodyHeight = screenSize.width / ratio; - return bodyHeight.clamp(0.0, maxHeight).toDouble(); + return clampRoomGameHeight(bodyHeight, maxBodyHeight); } if (screenMode == 'hd') { - final bodyHeight = screenSize.width * _hdDesignHeight / _designWidth; - return bodyHeight.clamp(0.0, maxHeight).toDouble(); + final bodyHeight = + screenSize.width * _hdDesignHeight / roomGameDesignWidth; + return clampRoomGameHeight(bodyHeight, maxBodyHeight); } - return fallback.clamp(0.0, maxHeight).toDouble(); + return clampRoomGameHeight(fallback, maxBodyHeight); } void _log(String message) { @@ -446,7 +440,51 @@ class _LeaderGamePageState extends State { } } - Map _buildLaunchPayload() { + Map _buildLaunchPayload({ + Map viewport = const {}, + }) { + final launchConfig = mergeRoomGameViewportPayload( + widget.launchModel.launchConfig.toJson(), + viewport, + ); + final payload = { + 'provider': widget.launchModel.provider, + 'gameId': widget.launchModel.gameId, + 'providerGameId': widget.launchModel.providerGameId, + 'gameSessionId': widget.launchModel.gameSessionId, + 'entry': widget.launchModel.entry.toJson(), + 'launchConfig': launchConfig, + 'roomState': widget.launchModel.roomState.toJson(), + 'game': { + 'gameId': widget.game.gameId, + 'provider': widget.game.provider, + 'gameType': widget.game.gameType, + 'name': widget.game.name, + 'launchMode': widget.game.launchMode, + }, + }; + return mergeRoomGameViewportPayload(payload, viewport); + } + + Map _buildViewportPayload() { + final headerHeight = 44.w; + final bodyHeight = _calculateBodyHeight(context, headerHeight); + final safeHeight = _resolveSafeHeight(); + return buildRoomGameViewportPayload( + context, + containerHeight: bodyHeight, + dialogHeight: bodyHeight + headerHeight, + headerHeight: headerHeight, + screenMode: _resolveScreenMode(), + safeHeight: safeHeight, + designHeight: + safeHeight > 0 + ? safeHeight.toDouble() + : (_resolveScreenMode() == 'hd' ? _hdDesignHeight : null), + ); + } + + Map _buildLaunchPayloadForLog() { return { 'provider': widget.launchModel.provider, 'gameId': widget.launchModel.gameId, @@ -466,7 +504,7 @@ class _LeaderGamePageState extends State { } Map _buildLaunchSummary() { - final payload = _buildLaunchPayload(); + final payload = _buildLaunchPayloadForLog(); return { 'roomId': widget.roomId, 'provider': widget.game.provider, @@ -525,8 +563,8 @@ class _LeaderGamePageState extends State { @override Widget build(BuildContext context) { - final bodyHeight = _calculateBodyHeight(context); final headerHeight = 44.w; + final bodyHeight = _calculateBodyHeight(context, headerHeight); return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) async { diff --git a/lib/modules/room_game/views/room_game_list_sheet.dart b/lib/modules/room_game/views/room_game_list_sheet.dart index 9ed7ff1..1a11b6c 100644 --- a/lib/modules/room_game/views/room_game_list_sheet.dart +++ b/lib/modules/room_game/views/room_game_list_sheet.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'dart:convert'; import 'package:yumi/modules/room_game/data/models/room_game_models.dart'; import 'package:yumi/modules/room_game/data/room_game_repository.dart'; +import 'package:yumi/modules/room_game/utils/room_game_viewport.dart'; import 'package:yumi/modules/room_game/views/baishun_game_page.dart'; import 'package:yumi/modules/room_game/views/leader_game_page.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; @@ -210,11 +211,17 @@ class _RoomGameListSheetState extends State { @override Widget build(BuildContext context) { + final mediaQuery = MediaQuery.maybeOf(context); + final screenHeight = mediaQuery?.size.height ?? ScreenUtil().screenHeight; + final sheetHeight = clampRoomGameHeight( + screenHeight * 0.5, + resolveRoomGameAvailableHeight(context), + ); return SafeArea( top: false, child: SizedBox( width: ScreenUtil().screenWidth, - height: ScreenUtil().screenHeight * 0.5, + height: sheetHeight, child: LayoutBuilder( builder: (context, constraints) { final innerLeft = constraints.maxWidth * 0.05; diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index 3c00843..4d3032d 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -46,6 +46,10 @@ import '../../ui_kit/components/sc_float_ichart.dart'; typedef OnSoundVoiceChange = Function(num index, int volum); typedef RtcProvider = RealTimeCommunicationManager; +enum RoomStartupStatus { idle, loading, ready, failed } + +enum RoomStartupFailureType { none, entry, im, rtc } + class RealTimeCommunicationManager extends ChangeNotifier { static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); @@ -57,6 +61,10 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool needUpDataUserInfo = false; bool _roomVisualEffectsEnabled = false; bool _isExitingCurrentVoiceRoomSession = false; + RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle; + RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none; + int _roomStartupFailureToken = 0; + bool _isHandlingRoomStartupFailure = false; Timer? _micListPollingTimer; Timer? _onlineUsersPollingTimer; bool _isRefreshingMicList = false; @@ -129,6 +137,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool get isExitingCurrentVoiceRoomSession => _isExitingCurrentVoiceRoomSession; + RoomStartupStatus get roomStartupStatus => _roomStartupStatus; + + RoomStartupFailureType get roomStartupFailureType => _roomStartupFailureType; + + int get roomStartupFailureToken => _roomStartupFailureToken; + bool get shouldShowRoomVisualEffects => currenRoom != null && _roomVisualEffectsEnabled; @@ -140,6 +154,35 @@ class RealTimeCommunicationManager extends ChangeNotifier { notifyListeners(); } + void _setRoomStartupLoading() { + _isHandlingRoomStartupFailure = false; + _roomStartupFailureType = RoomStartupFailureType.none; + if (_roomStartupStatus == RoomStartupStatus.loading) { + return; + } + _roomStartupStatus = RoomStartupStatus.loading; + notifyListeners(); + } + + void _setRoomStartupReady() { + _isHandlingRoomStartupFailure = false; + _roomStartupFailureType = RoomStartupFailureType.none; + if (_roomStartupStatus == RoomStartupStatus.ready) { + return; + } + _roomStartupStatus = RoomStartupStatus.ready; + notifyListeners(); + } + + void _setRoomStartupFailed(RoomStartupFailureType failureType) { + _stopRoomStatePolling(); + _isHandlingRoomStartupFailure = false; + _roomStartupFailureType = failureType; + _roomStartupFailureToken += 1; + _roomStartupStatus = RoomStartupStatus.failed; + notifyListeners(); + } + void _setExitingCurrentVoiceRoomSession(bool enabled, {bool notify = true}) { if (_isExitingCurrentVoiceRoomSession == enabled) { return; @@ -775,7 +818,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } - Future joinAgoraVoiceChannel() async { + Future joinAgoraVoiceChannel({bool throwOnError = false}) async { try { final rtcEngine = await _initAgoraRtcEngine(); rtcEngine.setAudioProfile( @@ -814,6 +857,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _syncSelfMicRuntimeState(); rtcEngine.muteAllRemoteAudioStreams(roomIsMute); } catch (e) { + if (throwOnError) { + rethrow; + } SCTts.show("Join room fail"); exitCurrentVoiceRoomSession(false); print('加入失败:${e.runtimeType},${e.toString()}'); @@ -1046,6 +1092,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { retrieveMicrophoneList(); fetchOnlineUsersList(); _startRoomStatePolling(); + _setRoomStartupReady(); setRoomVisualEffectsEnabled(true); SCFloatIchart().remove(); SCNavigatorUtils.push( @@ -1099,7 +1146,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } ///初始化房间相关 - Future initializeRoomSession({ + Future initializeRoomSession({ bool needOpenRedenvelope = false, String? redPackId, }) async { @@ -1112,7 +1159,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { currenRoom?.entrants?.roles == SCRoomRolesType.HOMEOWNER.name) { ///需要去修改房间信息,创建群聊 VoiceRoomRoute.openRoomEdit(context!, needRestCurrentRoomInfo: true); - return Future; + return; } SCRoomUtils.roomSCGlobalConfig( currenRoom?.roomProfile?.roomProfile?.id ?? "", @@ -1126,46 +1173,57 @@ class RealTimeCommunicationManager extends ChangeNotifier { notifyListeners(); }); } + _setRoomStartupLoading(); setRoomVisualEffectsEnabled(true); SCNavigatorUtils.push(context!, VoiceRoomRoute.voiceRoom, replace: false); - var joinResult = await rtmProvider?.joinRoomGroup( - currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", - "", - ); - SCChatRoomRepository() - .rocketClaim(currenRoom?.roomProfile?.roomProfile?.id ?? "") - .catchError((e) { - return true; // 错误已处理 - }); - rtmProvider?.addMsg( - Msg( - groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", - msg: SCAppLocalizations.of(context!)!.systemRoomTips, - type: SCRoomMsgType.systemTips, - ), - ); - rtmProvider?.addMsg( - Msg( - groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", - msg: currenRoom?.roomProfile?.roomProfile?.roomDesc, - type: SCRoomMsgType.systemTips, - ), - ); + unawaited(_bootstrapEnteredVoiceRoomSession()); + } - await _bootstrapRoomHeartbeatState(); + Future _bootstrapEnteredVoiceRoomSession() async { + var failureType = RoomStartupFailureType.im; + try { + final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; + final joinResult = await rtmProvider?.joinRoomGroup(groupId, ""); + if (joinResult == null || joinResult.code != 0) { + debugPrint( + '[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}', + ); + _setRoomStartupFailed(RoomStartupFailureType.im); + return; + } + + rtmProvider?.addMsg( + Msg( + groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", + msg: SCAppLocalizations.of(context!)!.systemRoomTips, + type: SCRoomMsgType.systemTips, + ), + ); + rtmProvider?.addMsg( + Msg( + groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", + msg: currenRoom?.roomProfile?.roomProfile?.roomDesc, + type: SCRoomMsgType.systemTips, + ), + ); + + await _bootstrapRoomHeartbeatState(); + + ///获取麦位 + retrieveMicrophoneList(); + fetchOnlineUsersList(); + _startRoomStatePolling(); + Provider.of( + context!, + listen: false, + ).fetchContributionLevelData( + currenRoom?.roomProfile?.roomProfile?.id ?? "", + ); + + failureType = RoomStartupFailureType.rtc; + await joinAgoraVoiceChannel(throwOnError: true); + _setRoomStartupReady(); - ///获取麦位 - retrieveMicrophoneList(); - fetchOnlineUsersList(); - _startRoomStatePolling(); - Provider.of( - context!, - listen: false, - ).fetchContributionLevelData( - currenRoom?.roomProfile?.roomProfile?.id ?? "", - ); - await joinAgoraVoiceChannel(); - if (joinResult?.code == 0) { Msg joinMsg = Msg( groupId: currenRoom?.roomProfile?.roomProfile?.roomAccount ?? "", user: AccountStorage().getCurrentUser()?.userProfile, @@ -1174,28 +1232,44 @@ class RealTimeCommunicationManager extends ChangeNotifier { type: SCRoomMsgType.joinRoom, ); rtmProvider?.dispatchMessage(joinMsg, addLocal: true); - if (SCGlobalConfig.isEntryVehicleAnimation) { - if (AccountStorage().getCurrentUser()?.userProfile?.getMountains() != - null && - shouldShowRoomVisualEffects) { - Future.delayed(Duration(milliseconds: 550), () { - SCGiftVapSvgaManager().play( - AccountStorage() - .getCurrentUser() - ?.userProfile - ?.getMountains() - ?.sourceUrl ?? - "", - priority: 100, - type: 1, - ); - }); - } - } - if (rtmProvider?.msgUserJoinListener != null) { - rtmProvider?.msgUserJoinListener!(joinMsg); + _playRoomEntryEffects(joinMsg); + _loadRoomSecondaryData(); + } catch (error, stackTrace) { + debugPrint('[RoomStartup] bootstrap failed: $error\n$stackTrace'); + _setRoomStartupFailed(failureType); + } + } + + void _playRoomEntryEffects(Msg joinMsg) { + if (SCGlobalConfig.isEntryVehicleAnimation) { + if (AccountStorage().getCurrentUser()?.userProfile?.getMountains() != + null && + shouldShowRoomVisualEffects) { + Future.delayed(Duration(milliseconds: 550), () { + SCGiftVapSvgaManager().play( + AccountStorage() + .getCurrentUser() + ?.userProfile + ?.getMountains() + ?.sourceUrl ?? + "", + priority: 100, + type: 1, + ); + }); } } + if (rtmProvider?.msgUserJoinListener != null) { + rtmProvider?.msgUserJoinListener!(joinMsg); + } + } + + void _loadRoomSecondaryData() { + SCChatRoomRepository() + .rocketClaim(currenRoom?.roomProfile?.roomProfile?.id ?? "") + .catchError((e) { + return true; // 错误已处理 + }); SCChatRoomRepository() .rocketStatus(currenRoom?.roomProfile?.roomProfile?.id ?? "") .then((res) { @@ -1391,6 +1465,45 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } + Future confirmRoomStartupFailureAndLeave({ + BuildContext? navigationContext, + }) async { + if (_isHandlingRoomStartupFailure) { + return; + } + _isHandlingRoomStartupFailure = true; + final failedRoom = currenRoom; + final groupId = failedRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; + final roomId = failedRoom?.roomProfile?.roomProfile?.id ?? ""; + final navigatorContext = navigationContext ?? context; + final navigator = + navigatorContext != null ? Navigator.maybeOf(navigatorContext) : null; + try { + _stopRoomStatePolling(); + SCGiftVapSvgaManager().stopPlayback(); + SCHeartbeatUtils.cancelTimer(); + SCHeartbeatUtils.cancelAnchorTimer(); + rtmProvider?.cleanRoomData(); + if (groupId.isNotEmpty) { + await rtmProvider?.quitGroup(groupId); + } + await engine?.leaveChannel(); + await Future.delayed(const Duration(milliseconds: 100)); + if (roomId.isNotEmpty) { + await SCAccountRepository().quitRoom(roomId); + } + } catch (e) { + print('rtc进房失败清理本地房间状态出错: $e'); + } finally { + _clearData(); + _isHandlingRoomStartupFailure = false; + notifyListeners(); + if (navigator != null && navigator.mounted && navigator.canPop()) { + navigator.pop(); + } + } + } + ///清空列表数据 void _clearData() { _stopRoomStatePolling(); @@ -1399,6 +1512,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { _disableMicListRefreshForCurrentSession = false; _disableOnlineUsersRefreshForCurrentSession = false; _isSelfMicActionInFlight = false; + _roomStartupStatus = RoomStartupStatus.idle; + _roomStartupFailureType = RoomStartupFailureType.none; + _isHandlingRoomStartupFailure = false; _pendingMicListRefresh = false; _pendingMicListRefreshNotifyIfUnchanged = false; _seatEmojiEventVersions.clear(); diff --git a/lib/shared/tools/sc_banner_utils.dart b/lib/shared/tools/sc_banner_utils.dart index b0e5245..16f1d2a 100644 --- a/lib/shared/tools/sc_banner_utils.dart +++ b/lib/shared/tools/sc_banner_utils.dart @@ -5,6 +5,7 @@ import 'package:yumi/shared/tools/sc_url_launcher_utils.dart'; import 'package:yumi/shared/business_logic/models/res/sc_index_banner_res.dart'; import 'package:yumi/modules/index/main_route.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; import '../../shared/data_sources/models/enum/sc_banner_open_type.dart'; @@ -35,12 +36,29 @@ class SCBannerUtils { } if (SCStringUtils.checkIfUrl(params)) { + final h5Url = _appendToken(params); SCNavigatorUtils.push( context, - "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(params)}&showTitle=false", + "${SCMainRoute.webViewPage}?url=${Uri.encodeComponent(h5Url)}&showTitle=false", replace: false, ); } } } + + static String _appendToken(String url) { + final token = AccountStorage().getToken(); + if (token.isEmpty) { + return url; + } + + final uri = Uri.tryParse(url); + if (uri == null) { + return url; + } + + final queryParameters = Map.from(uri.queryParameters); + queryParameters['token'] = token; + return uri.replace(queryParameters: queryParameters).toString(); + } } diff --git a/lib/ui_kit/components/dialog/dialog_base.dart b/lib/ui_kit/components/dialog/dialog_base.dart index e751a4c..142ffbf 100644 --- a/lib/ui_kit/components/dialog/dialog_base.dart +++ b/lib/ui_kit/components/dialog/dialog_base.dart @@ -1,5 +1,6 @@ import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_debouncer/flutter_debouncer.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -288,7 +289,16 @@ class OpenRoomLoadingDialog extends Dialog { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Image.asset("sc_images/raw/loading.webp", width: 80.w), + SizedBox( + width: 80.w, + height: 80.w, + child: Center( + child: CupertinoActivityIndicator( + color: Colors.white24, + radius: 14.w, + ), + ), + ), SizedBox(height: 12.w), Text( "正在进入聊天室......", diff --git a/需求进度.md b/需求进度.md index 42c5966..e6de69f 100644 --- a/需求进度.md +++ b/需求进度.md @@ -3,6 +3,13 @@ ## 当前总目标 - 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。 +## 本轮语言房进房加载逻辑优化(已完成) +- 已按竞品加载思路梳理当前语言房进房链路:当前业务 `entryRoom` 成功后会先进入房间页,IM 加群、声网入会、麦位与在线用户刷新在房间页展示后继续进行;这与“IM 和声网都完成后才进入完整房间”的体验存在差异。 +- 已把语言房进房过程改造为“先进入房间骨架页,再完成关键启动任务”的状态机:新增房间启动态,进入房间页后显示房内 loading;IM 加群和声网入会成功后才切到 ready;任一关键步骤失败则进入 failed。 +- 已将 IM 加群失败和声网入会失败统一收口到房间页阻断弹窗:失败后不再分散 toast 或静默失败,用户点击确认后会清理 IM 群、声网频道、心跳、轮询与本地房间状态,并返回上一级页面。 +- 已为进房失败与语音连接失败提示补齐 `en/ar/tr/bn` 多语言文案,避免新失败弹窗出现硬编码中文。 +- 当前麦位与在线用户仍保持分批加载策略:进入房间页后会继续拉麦位、在线用户并启动轮询,头像框、礼物、火箭、红包、榜单等非关键资源仍放在房间进入后的异步补齐链路,避免阻塞首屏骨架。 + ## 本轮语言房背景页框架(进行中) - 已按 2026-04-22 当前最新需求先落第一版页面骨架:新增两个独立路由,分别用于“背景选择页”和“自定义背景上传页”;其中语音房底部菜单里的 `Background` 图标入口,现已从旧的房间主题页切到新的背景选择页。 - 背景选择页已按“接近项目商店页”的结构先搭好导航和内容骨架:顶部为返回键、`官方 / 我的` 两个 tab 和右侧 `自定义` 文本按钮;点击右侧 `自定义` 会跳转到新的上传页。当前页内已先补上双列背景卡片栅格、选中高亮和“使用中”状态角标,便于后续继续接后台数据。