diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 04a89ee5..01a8d219 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -765,7 +765,8 @@ "Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" push)", "Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/pages/splash/splash_page.dart frontend/mining-app/lib/presentation/providers/user_providers.dart)", "Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")", - "Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")" + "Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")", + "Bash(git rm:*)" ], "deny": [], "ask": [] diff --git a/frontend/admin-web/public/drawable-xhdpi/background_1.png b/frontend/admin-web/public/drawable-xhdpi/background_1.png deleted file mode 100644 index bc62067f..00000000 Binary files a/frontend/admin-web/public/drawable-xhdpi/background_1.png and /dev/null differ diff --git a/frontend/admin-web/public/drawable-xhdpi/container_1.png b/frontend/admin-web/public/drawable-xhdpi/container_1.png new file mode 100644 index 00000000..7d58972b Binary files /dev/null and b/frontend/admin-web/public/drawable-xhdpi/container_1.png differ diff --git a/frontend/admin-web/public/drawable-xhdpi/user_avatar.png b/frontend/admin-web/public/drawable-xhdpi/user_avatar.png new file mode 100644 index 00000000..d3a33e64 Binary files /dev/null and b/frontend/admin-web/public/drawable-xhdpi/user_avatar.png differ diff --git a/frontend/admin-web/public/drawable-xxhdpi/background_1.png b/frontend/admin-web/public/drawable-xxhdpi/background_1.png deleted file mode 100644 index 700469d4..00000000 Binary files a/frontend/admin-web/public/drawable-xxhdpi/background_1.png and /dev/null differ diff --git a/frontend/admin-web/public/drawable-xxhdpi/container_1.png b/frontend/admin-web/public/drawable-xxhdpi/container_1.png new file mode 100644 index 00000000..bf2c8a3c Binary files /dev/null and b/frontend/admin-web/public/drawable-xxhdpi/container_1.png differ diff --git a/frontend/admin-web/public/drawable-xxhdpi/user_avatar.png b/frontend/admin-web/public/drawable-xxhdpi/user_avatar.png new file mode 100644 index 00000000..d9700825 Binary files /dev/null and b/frontend/admin-web/public/drawable-xxhdpi/user_avatar.png differ diff --git a/frontend/admin-web/public/drawable-xxxhdpi/background_1.png b/frontend/admin-web/public/drawable-xxxhdpi/background_1.png deleted file mode 100644 index 3d79ac6b..00000000 Binary files a/frontend/admin-web/public/drawable-xxxhdpi/background_1.png and /dev/null differ diff --git a/frontend/admin-web/public/drawable-xxxhdpi/container_1.png b/frontend/admin-web/public/drawable-xxxhdpi/container_1.png new file mode 100644 index 00000000..1660db78 Binary files /dev/null and b/frontend/admin-web/public/drawable-xxxhdpi/container_1.png differ diff --git a/frontend/admin-web/public/drawable-xxxhdpi/user_avatar.png b/frontend/admin-web/public/drawable-xxxhdpi/user_avatar.png new file mode 100644 index 00000000..a45c03c6 Binary files /dev/null and b/frontend/admin-web/public/drawable-xxxhdpi/user_avatar.png differ diff --git a/frontend/admin-web/public/drawable/background.xml b/frontend/admin-web/public/drawable/background.xml index 3b2f9cb3..3bb40986 100644 --- a/frontend/admin-web/public/drawable/background.xml +++ b/frontend/admin-web/public/drawable/background.xml @@ -5,9 +5,9 @@ android:viewportWidth="40" android:viewportHeight="40"> + android:fillColor="#FFFFEDD5" + android:pathData="M0 20c0-11.05 8.95-20 20-20h0c11.05 0 20 8.95 20 20v0c0 11.05-8.95 20-20 20h0c-11.05 0-20-8.95-20-20z"/> + android:pathData="M13.52 24.8l4.41-4.4 2.7 2.73c0.35 0.3 0.9 0.3 1.2-0.04l5.98-6.72c0.28-0.35 0.28-0.86-0.04-1.17-0.3-0.32-0.9-0.32-1.2 0.03l-5.32 5.98-2.73-2.73c-0.36-0.32-0.86-0.32-1.18 0l-5.07 5.07c-0.36 0.36-0.36 0.86 0 1.18l0.07 0.07c0.32 0.36 0.82 0.36 1.18 0Z"/> diff --git a/frontend/admin-web/public/drawable/background_1.xml b/frontend/admin-web/public/drawable/background_1.xml new file mode 100644 index 00000000..c6f43402 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_1.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_2.xml b/frontend/admin-web/public/drawable/background_2.xml new file mode 100644 index 00000000..0c0a36f2 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_2.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_3.xml b/frontend/admin-web/public/drawable/background_3.xml new file mode 100644 index 00000000..bae275a4 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_3.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_4.xml b/frontend/admin-web/public/drawable/background_4.xml new file mode 100644 index 00000000..52bbbfb0 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_4.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_shadow.xml b/frontend/admin-web/public/drawable/background_shadow.xml new file mode 100644 index 00000000..8b9bc0cd --- /dev/null +++ b/frontend/admin-web/public/drawable/background_shadow.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_shadow_1.xml b/frontend/admin-web/public/drawable/background_shadow_1.xml new file mode 100644 index 00000000..2580ed84 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_shadow_1.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_shadow_2.xml b/frontend/admin-web/public/drawable/background_shadow_2.xml new file mode 100644 index 00000000..dea48af2 --- /dev/null +++ b/frontend/admin-web/public/drawable/background_shadow_2.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/background_shadow_3.xml b/frontend/admin-web/public/drawable/background_shadow_3.xml new file mode 100644 index 00000000..f096d87b --- /dev/null +++ b/frontend/admin-web/public/drawable/background_shadow_3.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/admin-web/public/drawable/button.xml b/frontend/admin-web/public/drawable/button.xml index 05df329a..0edd6f0c 100644 --- a/frontend/admin-web/public/drawable/button.xml +++ b/frontend/admin-web/public/drawable/button.xml @@ -1,10 +1,13 @@ + android:width="36dp" + android:height="36dp" + android:viewportWidth="36" + android:viewportHeight="36"> + android:fillColor="#FFFFFFFF" + android:pathData="M0 18c0-9.94 8.06-18 18-18h0c9.94 0 18 8.06 18 18v0c0 9.94-8.06 18-18 18h0c-9.94 0-18-8.06-18-18z"/> + diff --git a/frontend/admin-web/public/drawable/button_1.xml b/frontend/admin-web/public/drawable/button_1.xml new file mode 100644 index 00000000..c701fe8f --- /dev/null +++ b/frontend/admin-web/public/drawable/button_1.xml @@ -0,0 +1,10 @@ + + + + diff --git a/frontend/admin-web/public/drawable/container_1.xml b/frontend/admin-web/public/drawable/container_1.xml deleted file mode 100644 index d7a14353..00000000 --- a/frontend/admin-web/public/drawable/container_1.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/frontend/admin-web/public/drawable/container_2.xml b/frontend/admin-web/public/drawable/container_2.xml index 2e61b46e..7b29c2a2 100644 --- a/frontend/admin-web/public/drawable/container_2.xml +++ b/frontend/admin-web/public/drawable/container_2.xml @@ -1,10 +1,10 @@ + android:fillColor="#FF4B5563" + android:pathData="M10 18.32c0.9 0 1.68-0.74 1.68-1.64H8.32c0 0.9 0.78 1.64 1.68 1.64Zm5-5V9.18c0-2.58-1.37-4.73-3.75-5.27V3.32c0-0.66-0.55-1.25-1.25-1.25S8.75 2.66 8.75 3.32v0.59C6.37 4.45 5 6.6 5 9.18v4.14L3.32 15v0.82h13.36V15L15 13.32Zm-1.68 0.86H6.68v-5c0-2.07 1.25-3.75 3.32-3.75s3.32 1.68 3.32 3.75v5Z"/> diff --git a/frontend/admin-web/public/drawable/container_3.xml b/frontend/admin-web/public/drawable/container_3.xml index 8506b426..3cc89e15 100644 --- a/frontend/admin-web/public/drawable/container_3.xml +++ b/frontend/admin-web/public/drawable/container_3.xml @@ -1,10 +1,10 @@ + android:width="14dp" + android:height="20dp" + android:viewportWidth="14" + android:viewportHeight="20"> + android:fillColor="#FF10B981" + android:pathData="M9.82 7l0.84 0.84-2.84 2.84-1.91-1.91c-0.22-0.25-0.6-0.25-0.82 0l-3.5 3.5c-0.25 0.22-0.25 0.6 0 0.82 0.21 0.22 0.6 0.22 0.82 0L5.5 10l1.91 1.91c0.22 0.25 0.6 0.25 0.82 0l3.25-3.25 0.85 0.85c0.2 0.16 0.5 0.05 0.5-0.22V6.8c0-0.16-0.12-0.3-0.28-0.3h-2.51C9.79 6.5 9.65 6.83 9.82 7Z"/> diff --git a/frontend/admin-web/public/drawable/container_4.xml b/frontend/admin-web/public/drawable/container_4.xml index 81588103..db32dcdd 100644 --- a/frontend/admin-web/public/drawable/container_4.xml +++ b/frontend/admin-web/public/drawable/container_4.xml @@ -1,10 +1,10 @@ + android:viewportHeight="32"> + android:pathData="M15.98 16.98c3.1-2.8 6-5.43 6-7.68 0-1.83-1.45-3.28-3.28-3.28-1.03 0-2.06 0.46-2.72 1.21-0.65-0.75-1.64-1.21-2.67-1.21-1.87 0-3.33 1.45-3.33 3.28 0 2.25 2.91 4.87 6 7.68Zm-2.67-9c0.42 0 0.9 0.24 1.17 0.57l1.5 1.78 1.55-1.78c0.28-0.33 0.75-0.57 1.17-0.57 0.75 0 1.32 0.57 1.32 1.32 0 1.12-2.07 3.18-4.04 5.01C14.06 12.48 12 10.42 12 9.3c0-0.75 0.56-1.32 1.31-1.32Zm5.67 12h-1.96c0-1.17-0.75-2.25-1.88-2.67l-6.19-2.3H0.98v10.97h6v-1.4l7.04 1.92 7.96-2.48v-1.04c0-1.64-1.3-3-3-3ZM3 24.02v-7.04h2.02v7.04H3Zm10.97 0.37l-6.99-1.92v-5.49h1.64l5.82 2.2c0.33 0.1 0.56 0.43 0.56 0.8 0 0-1.97-0.04-2.3-0.14l-2.39-0.8-0.6 1.93 2.34 0.8c0.51 0.14 1.07 0.23 1.6 0.23h5.33c0.43 0 0.75 0.23 0.94 0.56l-5.95 1.83Z"/> diff --git a/frontend/admin-web/public/drawable/container_5.xml b/frontend/admin-web/public/drawable/container_5.xml index 98773d7f..9486a551 100644 --- a/frontend/admin-web/public/drawable/container_5.xml +++ b/frontend/admin-web/public/drawable/container_5.xml @@ -1,10 +1,10 @@ + android:viewportHeight="32"> + android:pathData="M6.98 15.02L3 19l3.98 3.98v-3h7.04v-1.96H6.98v-3ZM21 13l-3.98-3.98v3H9.98v1.96h7.04v3L21 13Z"/> diff --git a/frontend/admin-web/public/drawable/container_6.xml b/frontend/admin-web/public/drawable/container_6.xml index 89f51a10..e393f05a 100644 --- a/frontend/admin-web/public/drawable/container_6.xml +++ b/frontend/admin-web/public/drawable/container_6.xml @@ -1,10 +1,10 @@ + android:viewportHeight="32"> + android:pathData="M12 10c1.08 0 2.02 0.9 2.02 2.02 0 1.07-0.94 1.96-2.02 1.96s-2.02-0.89-2.02-1.96C9.98 10.89 10.92 10 12 10Zm0 9.98c2.72 0 5.81 1.32 6 2.02H6c0.23-0.7 3.33-2.02 6-2.02Zm0-12c-2.2 0-3.98 1.83-3.98 4.04C8.02 14.22 9.8 16 12 16s3.98-1.78 3.98-3.98S14.2 7.98 12 7.98Zm0 10.04c-2.67 0-8.02 1.3-8.02 3.98v2.02h16.04V22c0-2.67-5.35-3.98-8.02-3.98Z"/> diff --git a/frontend/admin-web/public/drawable/container_7.xml b/frontend/admin-web/public/drawable/container_7.xml index feee424c..2ab9f00e 100644 --- a/frontend/admin-web/public/drawable/container_7.xml +++ b/frontend/admin-web/public/drawable/container_7.xml @@ -1,10 +1,10 @@ + android:width="14dp" + android:height="20dp" + android:viewportWidth="14" + android:viewportHeight="20"> + android:fillColor="#FFD1D5DB" + android:pathData="M3.64 14.81l1.04 1.01L10.5 10 4.68 4.18l-1.04 1L8.42 10l-4.78 4.81Z"/> diff --git a/frontend/admin-web/public/drawable/margin.xml b/frontend/admin-web/public/drawable/margin.xml new file mode 100644 index 00000000..84585c0a --- /dev/null +++ b/frontend/admin-web/public/drawable/margin.xml @@ -0,0 +1,10 @@ + + + + diff --git a/frontend/admin-web/public/drawable/margin_1.xml b/frontend/admin-web/public/drawable/margin_1.xml new file mode 100644 index 00000000..fde00eed --- /dev/null +++ b/frontend/admin-web/public/drawable/margin_1.xml @@ -0,0 +1,10 @@ + + + + diff --git a/frontend/mining-app/lib/core/network/api_client.dart b/frontend/mining-app/lib/core/network/api_client.dart index 8d3bcee0..70202f29 100644 --- a/frontend/mining-app/lib/core/network/api_client.dart +++ b/frontend/mining-app/lib/core/network/api_client.dart @@ -6,6 +6,13 @@ import '../error/exceptions.dart'; class ApiClient { final Dio dio; + // 缓存 SharedPreferences 实例,避免每次请求都阻塞读取 + static SharedPreferences? _prefsCache; + static Future? _prefsFuture; + + // 全局回调,用于处理 401 跳转登录 + static void Function()? onUnauthorized; + ApiClient({required this.dio}) { dio.options = BaseOptions( baseUrl: AppConstants.baseUrl, @@ -20,17 +27,38 @@ class ApiClient { // 添加请求拦截器,自动注入 token dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { - // 每次请求都从存储中读取最新的 token,确保登录后立即生效 - final prefs = await SharedPreferences.getInstance(); + // 使用缓存的 SharedPreferences 实例,避免阻塞 + final prefs = await _getPrefs(); final token = prefs.getString('access_token'); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, + onError: (error, handler) { + // 全局处理 401 错误 + if (error.response?.statusCode == 401) { + onUnauthorized?.call(); + } + handler.next(error); + }, )); } + // 获取缓存的 SharedPreferences 实例 + static Future _getPrefs() async { + if (_prefsCache != null) return _prefsCache!; + _prefsFuture ??= SharedPreferences.getInstance(); + _prefsCache = await _prefsFuture; + return _prefsCache!; + } + + // 清除缓存(登录/登出时调用) + static void clearPrefsCache() { + _prefsCache = null; + _prefsFuture = null; + } + Future get( String path, { Map? queryParameters, diff --git a/frontend/mining-app/lib/main.dart b/frontend/mining-app/lib/main.dart index b0b8cb0a..d68b8702 100644 --- a/frontend/mining-app/lib/main.dart +++ b/frontend/mining-app/lib/main.dart @@ -4,6 +4,9 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/constants/app_colors.dart'; +import 'core/network/api_client.dart'; +import 'core/router/routes.dart'; +import 'presentation/providers/user_providers.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -17,11 +20,31 @@ void main() async { runApp(const ProviderScope(child: MiningApp())); } -class MiningApp extends ConsumerWidget { +class MiningApp extends ConsumerStatefulWidget { const MiningApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MiningAppState(); +} + +class _MiningAppState extends ConsumerState { + @override + void initState() { + super.initState(); + // 设置 401 全局处理回调 + ApiClient.onUnauthorized = _handleUnauthorized; + } + + void _handleUnauthorized() { + // 清除用户状态 + ref.read(userNotifierProvider.notifier).logout(); + // 跳转到登录页 + final router = ref.read(appRouterProvider); + router.go(Routes.login); + } + + @override + Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); return MaterialApp.router( diff --git a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart index 9033e3c7..6fb55b98 100644 --- a/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart +++ b/frontend/mining-app/lib/presentation/pages/asset/asset_page.dart @@ -4,6 +4,7 @@ import '../../../core/constants/app_colors.dart'; import '../../../core/utils/format_utils.dart'; import '../../providers/user_providers.dart'; import '../../providers/mining_providers.dart'; +import '../../widgets/shimmer_loading.dart'; class AssetPage extends ConsumerWidget { const AssetPage({super.key}); @@ -14,7 +15,6 @@ class AssetPage extends ConsumerWidget { static const Color _grayText = Color(0xFF6B7280); static const Color _darkText = Color(0xFF1F2937); static const Color _bgGray = Color(0xFFF3F4F6); - static const Color _lightGray = Color(0xFFF9FAFB); static const Color _riverBed = Color(0xFF4B5563); static const Color _serenade = Color(0xFFFFF7ED); static const Color _feta = Color(0xFFF0FDF4); @@ -64,7 +64,7 @@ class AssetPage extends ConsumerWidget { // 资产列表 accountAsync.when( data: (account) => _buildAssetList(account), - loading: () => _buildLoadingCard(), + loading: () => _buildAssetListSkeleton(), error: (_, __) => const SizedBox.shrink(), ), const SizedBox(height: 24), @@ -74,7 +74,7 @@ class AssetPage extends ConsumerWidget { // 账户列表 accountAsync.when( data: (account) => _buildAccountList(account), - loading: () => _buildLoadingCard(), + loading: () => _buildAssetListSkeleton(), error: (_, __) => const SizedBox.shrink(), ), const SizedBox(height: 100), @@ -767,13 +767,18 @@ class AssetPage extends ConsumerWidget { } Widget _buildLoadingCard() { - return Container( - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: const Center(child: CircularProgressIndicator()), + return const AssetCardSkeleton(); + } + + Widget _buildAssetListSkeleton() { + return Column( + children: const [ + AssetItemSkeleton(), + SizedBox(height: 16), + AssetItemSkeleton(), + SizedBox(height: 16), + AssetItemSkeleton(), + ], ); } diff --git a/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart b/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart index 535214bf..541d0617 100644 --- a/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart +++ b/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart @@ -7,6 +7,7 @@ import '../../../domain/entities/contribution.dart'; import '../../../domain/entities/contribution_record.dart'; import '../../providers/user_providers.dart'; import '../../providers/contribution_providers.dart'; +import '../../widgets/shimmer_loading.dart'; class ContributionPage extends ConsumerWidget { const ContributionPage({super.key}); @@ -75,7 +76,7 @@ class ContributionPage extends ConsumerWidget { ], ); }, - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const PageSkeleton(), error: (error, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -382,12 +383,16 @@ class ContributionPage extends ConsumerWidget { }).toList(), ); }, - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + loading: () => Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: ShimmerLoading( + child: Column( + children: const [ + ShimmerBox(height: 48), + SizedBox(height: 12), + ShimmerBox(height: 48), + ], + ), ), ), error: (error, _) => Padding( diff --git a/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart b/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart index 4463565b..6d9f4e69 100644 --- a/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart +++ b/frontend/mining-app/lib/presentation/pages/splash/splash_page.dart @@ -25,28 +25,21 @@ class _SplashPageState extends ConsumerState { } Future _initialize() async { - await Future.delayed(const Duration(seconds: 2)); + // 最多等 500ms 展示品牌,然后立即跳转,不阻塞用户 + await Future.delayed(const Duration(milliseconds: 500)); if (!mounted) return; - // 检查用户登录状态 final userState = ref.read(userNotifierProvider); if (userState.isLoggedIn) { - // 已登录,尝试刷新token - try { - await ref.read(userNotifierProvider.notifier).refreshTokenIfNeeded(); - if (mounted) { - context.go(Routes.contribution); - } - } catch (e) { - // token刷新失败,跳转到登录页 - if (mounted) { - context.go(Routes.login); - } + // 立即跳转,不等待 token 刷新 + if (mounted) { + context.go(Routes.contribution); } + // 后台刷新 token,失败了由 API 拦截器处理 401 + ref.read(userNotifierProvider.notifier).refreshTokenIfNeeded(); } else { - // 未登录,跳转到登录页 context.go(Routes.login); } } diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index f138d068..09272d0d 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -5,6 +5,7 @@ import '../../../core/utils/format_utils.dart'; import '../../providers/mining_providers.dart'; import '../../providers/user_providers.dart'; import '../../providers/trading_providers.dart'; +import '../../widgets/shimmer_loading.dart'; class TradingPage extends ConsumerStatefulWidget { const TradingPage({super.key}); @@ -767,14 +768,25 @@ class _TradingPageState extends ConsumerState { } Widget _buildLoadingCard() { - return Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), + return ShimmerLoading( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + ShimmerBox(width: 100, height: 16), + SizedBox(height: 12), + ShimmerBox(width: 150, height: 28), + SizedBox(height: 8), + ShimmerBox(width: 80, height: 14), + ], + ), ), - child: const Center(child: CircularProgressIndicator()), ); } @@ -830,7 +842,9 @@ class _TradingPageState extends ConsumerState { ); if (success) { _amountController.clear(); + // 交易成功后刷新所有相关数据 ref.invalidate(shareAccountProvider(accountSequence)); + ref.invalidate(globalStateProvider); } } } diff --git a/frontend/mining-app/lib/presentation/providers/contribution_providers.dart b/frontend/mining-app/lib/presentation/providers/contribution_providers.dart index 87a26685..ab17eaa6 100644 --- a/frontend/mining-app/lib/presentation/providers/contribution_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/contribution_providers.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/contribution.dart'; import '../../domain/entities/contribution_record.dart'; @@ -15,8 +16,21 @@ final contributionRepositoryProvider = Provider((ref) { final contributionProvider = FutureProvider.family( (ref, accountSequence) async { + // 空字符串不请求 + if (accountSequence.isEmpty) return null; + final useCase = ref.watch(getUserContributionUseCaseProvider); final result = await useCase(accountSequence); + + // 保持 provider 活跃,避免重复请求 + ref.keepAlive(); + + // 5 分钟后自动失效 + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + return result.fold( (failure) => throw Exception(failure.message), (contribution) => contribution, @@ -52,12 +66,25 @@ class ContributionRecordsParams { /// 贡献值记录 Provider final contributionRecordsProvider = FutureProvider.family( (ref, params) async { + // 空字符串不请求 + if (params.accountSequence.isEmpty) return null; + final repository = ref.watch(contributionRepositoryProvider); final result = await repository.getContributionRecords( params.accountSequence, page: params.page, pageSize: params.pageSize, ); + + // 保持 provider 活跃 + ref.keepAlive(); + + // 5 分钟后自动失效 + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + return result.fold( (failure) => throw Exception(failure.message), (records) => records, diff --git a/frontend/mining-app/lib/presentation/providers/mining_providers.dart b/frontend/mining-app/lib/presentation/providers/mining_providers.dart index d518c81a..26ab9ec7 100644 --- a/frontend/mining-app/lib/presentation/providers/mining_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/mining_providers.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/share_account.dart'; import '../../domain/entities/global_state.dart'; @@ -14,11 +15,24 @@ final getGlobalStateUseCaseProvider = Provider((ref) { return getIt(); }); -// State Providers +// State Providers - 使用 keepAlive 缓存数据,避免重复请求 final shareAccountProvider = FutureProvider.family( (ref, accountSequence) async { + // 空字符串不请求 + if (accountSequence.isEmpty) return null; + final useCase = ref.watch(getShareAccountUseCaseProvider); final result = await useCase(accountSequence); + + // 保持 provider 活跃,避免每次 rebuild 都重新请求 + ref.keepAlive(); + + // 5 分钟后自动失效,允许刷新 + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + return result.fold( (failure) => throw Exception(failure.message), (account) => account, @@ -29,6 +43,16 @@ final shareAccountProvider = FutureProvider.family( final globalStateProvider = FutureProvider((ref) async { final useCase = ref.watch(getGlobalStateUseCaseProvider); final result = await useCase(); + + // 保持 provider 活跃 + ref.keepAlive(); + + // 5 分钟后自动失效 + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + return result.fold( (failure) => throw Exception(failure.message), (state) => state, diff --git a/frontend/mining-app/lib/presentation/widgets/shimmer_loading.dart b/frontend/mining-app/lib/presentation/widgets/shimmer_loading.dart new file mode 100644 index 00000000..44ba8939 --- /dev/null +++ b/frontend/mining-app/lib/presentation/widgets/shimmer_loading.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; + +/// 骨架屏加载组件 - 提供更好的加载体验 +class ShimmerLoading extends StatefulWidget { + final Widget child; + final bool isLoading; + + const ShimmerLoading({ + super.key, + required this.child, + this.isLoading = true, + }); + + @override + State createState() => _ShimmerLoadingState(); +} + +class _ShimmerLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + _animation = Tween(begin: -2, end: 2).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.isLoading) return widget.child; + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: const [ + Color(0xFFEBEBF4), + Color(0xFFF4F4F4), + Color(0xFFEBEBF4), + ], + stops: [ + _animation.value - 1, + _animation.value, + _animation.value + 1, + ].map((e) => e.clamp(0.0, 1.0)).toList(), + ).createShader(bounds); + }, + blendMode: BlendMode.srcATop, + child: widget.child, + ); + }, + ); + } +} + +/// 骨架屏占位框 +class ShimmerBox extends StatelessWidget { + final double? width; + final double height; + final double borderRadius; + + const ShimmerBox({ + super.key, + this.width, + required this.height, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: const Color(0xFFE5E7EB), + borderRadius: BorderRadius.circular(borderRadius), + ), + ); + } +} + +/// 资产卡片骨架屏 +class AssetCardSkeleton extends StatelessWidget { + const AssetCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 30, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerBox(width: 80, height: 16), + const SizedBox(height: 12), + const ShimmerBox(width: 180, height: 36), + const SizedBox(height: 8), + const ShimmerBox(width: 120, height: 14), + const SizedBox(height: 12), + const ShimmerBox(width: 100, height: 24, borderRadius: 12), + ], + ), + ), + ); + } +} + +/// 资产项目骨架屏 +class AssetItemSkeleton extends StatelessWidget { + const AssetItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + const ShimmerBox(width: 40, height: 40, borderRadius: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ShimmerBox(width: 60, height: 16), + const SizedBox(height: 8), + const ShimmerBox(width: 100, height: 20), + const SizedBox(height: 4), + const ShimmerBox(width: 80, height: 12), + ], + ), + ), + const ShimmerBox(width: 14, height: 20), + ], + ), + ), + ); + } +} + +/// 收益统计骨架屏 +class EarningsStatsSkeleton extends StatelessWidget { + const EarningsStatsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + color: const Color(0xFFE5E7EB), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const ShimmerBox(width: 60, height: 16), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Column( + children: const [ + ShimmerBox(width: 50, height: 12), + SizedBox(height: 8), + ShimmerBox(width: 70, height: 16), + ], + ), + ), + Container( + width: 1, + height: 40, + color: const Color(0xFFE5E7EB), + ), + Expanded( + child: Column( + children: const [ + ShimmerBox(width: 50, height: 12), + SizedBox(height: 8), + ShimmerBox(width: 70, height: 16), + ], + ), + ), + Container( + width: 1, + height: 40, + color: const Color(0xFFE5E7EB), + ), + Expanded( + child: Column( + children: const [ + ShimmerBox(width: 50, height: 12), + SizedBox(height: 8), + ShimmerBox(width: 70, height: 16), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// 页面骨架屏 +class PageSkeleton extends StatelessWidget { + const PageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + AssetCardSkeleton(), + SizedBox(height: 24), + AssetItemSkeleton(), + SizedBox(height: 16), + AssetItemSkeleton(), + SizedBox(height: 16), + AssetItemSkeleton(), + SizedBox(height: 24), + EarningsStatsSkeleton(), + ], + ), + ); + } +}