From add405aa651aafd1036069e060a42627138a3ddb Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 12 Jan 2026 09:39:23 -0800 Subject: [PATCH] feat(mining-app): fix login bugs and connect contribution page to real API Login fixes: - Add AuthEventBus for global 401 error handling with auto-logout - Add route guards with GoRouter redirect to protect authenticated routes - Remove setMockUser() security vulnerability and legacy login() dead code - Remove unused AuthInterceptor class Contribution page: - Add ContributionRecord entity and model for records API - Connect contribution details card to GET /accounts/{id}/records endpoint - Display real team stats (direct referrals, unlocked levels/tiers) - Calculate expiration countdown from actual record data Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 3 +- .../mining-app/lib/core/di/injection.dart | 1 - .../lib/core/network/api_client.dart | 24 +++ .../lib/core/network/interceptors.dart | 20 -- .../lib/core/router/app_router.dart | 76 ++++++++ .../contribution_remote_datasource.dart | 49 +++++ .../models/contribution_record_model.dart | 71 +++++++ .../contribution_repository_impl.dart | 25 +++ .../domain/entities/contribution_record.dart | 96 ++++++++++ .../repositories/contribution_repository.dart | 8 + .../pages/contribution/contribution_page.dart | 181 ++++++++++++++---- .../providers/contribution_providers.dart | 47 +++++ .../providers/user_providers.dart | 23 --- 13 files changed, 541 insertions(+), 83 deletions(-) create mode 100644 frontend/mining-app/lib/data/models/contribution_record_model.dart create mode 100644 frontend/mining-app/lib/domain/entities/contribution_record.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a2f13198..e6ffa67d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -755,7 +755,8 @@ "Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")", "Bash(set DATABASE_URL=postgresql://user:pass@localhost:5432/db)", "Bash(cmd /c \"set DATABASE_URL=postgresql://user:pass@localhost:5432/db && npx prisma migrate dev --name add_nickname_to_synced_legacy_users --create-only\")", - "Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")" + "Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")", + "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-app\\): fix login bugs and connect contribution page to real API\n\nLogin fixes:\n- Add AuthEventBus for global 401 error handling with auto-logout\n- Add route guards with GoRouter redirect to protect authenticated routes\n- Remove setMockUser\\(\\) security vulnerability and legacy login\\(\\) dead code\n- Remove unused AuthInterceptor class\n\nContribution page:\n- Add ContributionRecord entity and model for records API\n- Connect contribution details card to GET /accounts/{id}/records endpoint\n- Display real team stats \\(direct referrals, unlocked levels/tiers\\)\n- Calculate expiration countdown from actual record data\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" ], "deny": [], "ask": [] diff --git a/frontend/mining-app/lib/core/di/injection.dart b/frontend/mining-app/lib/core/di/injection.dart index 1be9d596..7d94427b 100644 --- a/frontend/mining-app/lib/core/di/injection.dart +++ b/frontend/mining-app/lib/core/di/injection.dart @@ -25,7 +25,6 @@ final getIt = GetIt.instance; Future configureDependencies() async { // Dio final dio = Dio(); - dio.interceptors.add(AuthInterceptor()); dio.interceptors.add(LoggingInterceptor()); getIt.registerSingleton(dio); diff --git a/frontend/mining-app/lib/core/network/api_client.dart b/frontend/mining-app/lib/core/network/api_client.dart index 8d3bcee0..d7e8921d 100644 --- a/frontend/mining-app/lib/core/network/api_client.dart +++ b/frontend/mining-app/lib/core/network/api_client.dart @@ -1,8 +1,30 @@ +import 'dart:async'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../constants/app_constants.dart'; import '../error/exceptions.dart'; +/// 全局未授权事件控制器,用于通知 401 错误 +class AuthEventBus { + static final AuthEventBus _instance = AuthEventBus._internal(); + factory AuthEventBus() => _instance; + AuthEventBus._internal(); + + final _unauthorizedController = StreamController.broadcast(); + + /// 监听未授权事件 + Stream get onUnauthorized => _unauthorizedController.stream; + + /// 触发未授权事件 + void emitUnauthorized() { + _unauthorizedController.add(null); + } + + void dispose() { + _unauthorizedController.close(); + } +} + class ApiClient { final Dio dio; @@ -107,6 +129,8 @@ class ApiClient { case DioExceptionType.badResponse: final statusCode = e.response?.statusCode; if (statusCode == 401) { + // 触发全局未授权事件,通知应用进行登出处理 + AuthEventBus().emitUnauthorized(); return UnauthorizedException(); } final message = e.response?.data?['error']?['message']?[0] ?? '服务器错误'; diff --git a/frontend/mining-app/lib/core/network/interceptors.dart b/frontend/mining-app/lib/core/network/interceptors.dart index 27412320..459a2f99 100644 --- a/frontend/mining-app/lib/core/network/interceptors.dart +++ b/frontend/mining-app/lib/core/network/interceptors.dart @@ -1,26 +1,6 @@ import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; -class AuthInterceptor extends Interceptor { - String? _token; - - void setToken(String token) { - _token = token; - } - - void clearToken() { - _token = null; - } - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - if (_token != null) { - options.headers['Authorization'] = 'Bearer $_token'; - } - handler.next(options); - } -} - class LoggingInterceptor extends Interceptor { final _logger = Logger( printer: PrettyPrinter(methodCount: 0), diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 63e67997..cc7774ac 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../presentation/pages/splash/splash_page.dart'; @@ -11,11 +13,85 @@ import '../../presentation/pages/trading/trading_page.dart'; import '../../presentation/pages/asset/asset_page.dart'; import '../../presentation/pages/profile/profile_page.dart'; import '../../presentation/widgets/main_shell.dart'; +import '../../presentation/providers/user_providers.dart'; +import '../network/api_client.dart'; import 'routes.dart'; +/// 不需要登录就能访问的公开路由 +const _publicRoutes = { + Routes.splash, + Routes.login, + Routes.register, + Routes.forgotPassword, +}; + +/// 路由刷新通知器,用于在登录状态变化时刷新路由 +class AuthNotifier extends ChangeNotifier { + bool _isLoggedIn = false; + StreamSubscription? _unauthorizedSubscription; + + AuthNotifier() { + // 监听 401 未授权事件 + _unauthorizedSubscription = AuthEventBus().onUnauthorized.listen((_) { + _isLoggedIn = false; + notifyListeners(); + }); + } + + bool get isLoggedIn => _isLoggedIn; + + void updateLoginState(bool isLoggedIn) { + if (_isLoggedIn != isLoggedIn) { + _isLoggedIn = isLoggedIn; + notifyListeners(); + } + } + + @override + void dispose() { + _unauthorizedSubscription?.cancel(); + super.dispose(); + } +} + +final authNotifierProvider = ChangeNotifierProvider((ref) { + final authNotifier = AuthNotifier(); + + // 监听用户登录状态变化 + ref.listen(isLoggedInProvider, (previous, next) { + authNotifier.updateLoginState(next); + }); + + return authNotifier; +}); + final appRouterProvider = Provider((ref) { + final authNotifier = ref.watch(authNotifierProvider); + return GoRouter( initialLocation: Routes.splash, + refreshListenable: authNotifier, + redirect: (context, state) { + final isLoggedIn = ref.read(isLoggedInProvider); + final currentPath = state.uri.path; + + // splash 页面不做重定向,让它自己处理初始化逻辑 + if (currentPath == Routes.splash) { + return null; + } + + // 未登录且访问受保护路由,重定向到登录页 + if (!isLoggedIn && !_publicRoutes.contains(currentPath)) { + return Routes.login; + } + + // 已登录且访问登录页,重定向到首页 + if (isLoggedIn && currentPath == Routes.login) { + return Routes.contribution; + } + + return null; + }, routes: [ GoRoute( path: Routes.splash, diff --git a/frontend/mining-app/lib/data/datasources/remote/contribution_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/contribution_remote_datasource.dart index 35ea0a96..5853d2b1 100644 --- a/frontend/mining-app/lib/data/datasources/remote/contribution_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/contribution_remote_datasource.dart @@ -1,10 +1,19 @@ import '../../models/contribution_model.dart'; +import '../../models/contribution_record_model.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; import '../../../core/error/exceptions.dart'; +import '../../../domain/entities/contribution_record.dart'; abstract class ContributionRemoteDataSource { Future getUserContribution(String accountSequence); + Future getContributionRecords( + String accountSequence, { + ContributionSourceType? sourceType, + bool includeExpired = false, + int page = 1, + int pageSize = 50, + }); } class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource { @@ -21,4 +30,44 @@ class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future getContributionRecords( + String accountSequence, { + ContributionSourceType? sourceType, + bool includeExpired = false, + int page = 1, + int pageSize = 50, + }) async { + try { + final queryParams = { + 'page': page, + 'pageSize': pageSize, + 'includeExpired': includeExpired, + }; + + if (sourceType != null) { + queryParams['sourceType'] = _sourceTypeToString(sourceType); + } + + final response = await client.get( + ApiEndpoints.contributionRecords(accountSequence), + queryParameters: queryParams, + ); + return ContributionRecordsPageModel.fromJson(response.data); + } catch (e) { + throw ServerException(e.toString()); + } + } + + String _sourceTypeToString(ContributionSourceType type) { + switch (type) { + case ContributionSourceType.personal: + return 'PERSONAL'; + case ContributionSourceType.teamLevel: + return 'TEAM_LEVEL'; + case ContributionSourceType.teamBonus: + return 'TEAM_BONUS'; + } + } } diff --git a/frontend/mining-app/lib/data/models/contribution_record_model.dart b/frontend/mining-app/lib/data/models/contribution_record_model.dart new file mode 100644 index 00000000..bcdfc4d8 --- /dev/null +++ b/frontend/mining-app/lib/data/models/contribution_record_model.dart @@ -0,0 +1,71 @@ +import '../../domain/entities/contribution_record.dart'; + +class ContributionRecordModel extends ContributionRecord { + const ContributionRecordModel({ + required super.id, + required super.sourceType, + required super.sourceAdoptionId, + super.sourceAccountSequence, + required super.treeCount, + required super.baseContribution, + required super.distributionRate, + super.levelDepth, + super.bonusTier, + required super.finalContribution, + required super.effectiveDate, + required super.expireDate, + required super.isExpired, + required super.createdAt, + }); + + factory ContributionRecordModel.fromJson(Map json) { + return ContributionRecordModel( + id: json['id']?.toString() ?? '', + sourceType: _parseSourceType(json['sourceType']), + sourceAdoptionId: json['sourceAdoptionId']?.toString() ?? '', + sourceAccountSequence: json['sourceAccountSequence']?.toString(), + treeCount: json['treeCount'] ?? 0, + baseContribution: json['baseContribution']?.toString() ?? '0', + distributionRate: json['distributionRate']?.toString() ?? '0', + levelDepth: json['levelDepth'], + bonusTier: json['bonusTier'], + finalContribution: json['finalContribution']?.toString() ?? '0', + effectiveDate: DateTime.parse(json['effectiveDate']), + expireDate: DateTime.parse(json['expireDate']), + isExpired: json['isExpired'] == true, + createdAt: DateTime.parse(json['createdAt']), + ); + } + + static ContributionSourceType _parseSourceType(String? type) { + switch (type) { + case 'PERSONAL': + return ContributionSourceType.personal; + case 'TEAM_LEVEL': + return ContributionSourceType.teamLevel; + case 'TEAM_BONUS': + return ContributionSourceType.teamBonus; + default: + return ContributionSourceType.personal; + } + } +} + +class ContributionRecordsPageModel extends ContributionRecordsPage { + const ContributionRecordsPageModel({ + required super.data, + required super.total, + required super.page, + required super.pageSize, + }); + + factory ContributionRecordsPageModel.fromJson(Map json) { + final dataList = (json['data'] as List?) ?? []; + return ContributionRecordsPageModel( + data: dataList.map((e) => ContributionRecordModel.fromJson(e)).toList(), + total: json['total'] ?? 0, + page: json['page'] ?? 1, + pageSize: json['pageSize'] ?? 50, + ); + } +} diff --git a/frontend/mining-app/lib/data/repositories/contribution_repository_impl.dart b/frontend/mining-app/lib/data/repositories/contribution_repository_impl.dart index 19026a8a..7d9d3ab7 100644 --- a/frontend/mining-app/lib/data/repositories/contribution_repository_impl.dart +++ b/frontend/mining-app/lib/data/repositories/contribution_repository_impl.dart @@ -1,5 +1,6 @@ import 'package:dartz/dartz.dart'; import '../../domain/entities/contribution.dart'; +import '../../domain/entities/contribution_record.dart'; import '../../domain/repositories/contribution_repository.dart'; import '../../core/error/exceptions.dart'; import '../../core/error/failures.dart'; @@ -21,4 +22,28 @@ class ContributionRepositoryImpl implements ContributionRepository { return Left(const NetworkFailure()); } } + + @override + Future> getContributionRecords( + String accountSequence, { + ContributionSourceType? sourceType, + bool includeExpired = false, + int page = 1, + int pageSize = 50, + }) async { + try { + final result = await remoteDataSource.getContributionRecords( + accountSequence, + sourceType: sourceType, + includeExpired: includeExpired, + page: page, + pageSize: pageSize, + ); + return Right(result); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException { + return Left(const NetworkFailure()); + } + } } diff --git a/frontend/mining-app/lib/domain/entities/contribution_record.dart b/frontend/mining-app/lib/domain/entities/contribution_record.dart new file mode 100644 index 00000000..effccdcc --- /dev/null +++ b/frontend/mining-app/lib/domain/entities/contribution_record.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; + +/// 贡献值来源类型 +enum ContributionSourceType { + personal, // 个人 - 认种榴莲树 + teamLevel, // 团队层级 - 直推/间推奖励 + teamBonus, // 团队奖励 - 额外奖励 +} + +/// 贡献值记录 +class ContributionRecord extends Equatable { + final String id; + final ContributionSourceType sourceType; + final String sourceAdoptionId; + final String? sourceAccountSequence; + final int treeCount; + final String baseContribution; + final String distributionRate; + final int? levelDepth; + final int? bonusTier; + final String finalContribution; + final DateTime effectiveDate; + final DateTime expireDate; + final bool isExpired; + final DateTime createdAt; + + const ContributionRecord({ + required this.id, + required this.sourceType, + required this.sourceAdoptionId, + this.sourceAccountSequence, + required this.treeCount, + required this.baseContribution, + required this.distributionRate, + this.levelDepth, + this.bonusTier, + required this.finalContribution, + required this.effectiveDate, + required this.expireDate, + required this.isExpired, + required this.createdAt, + }); + + /// 获取记录的显示标题 + String get displayTitle { + switch (sourceType) { + case ContributionSourceType.personal: + return '认种榴莲树'; + case ContributionSourceType.teamLevel: + if (levelDepth == 1) { + return '直推奖励'; + } + return '团队奖励($levelDepth级)'; + case ContributionSourceType.teamBonus: + return '团队额外奖励'; + } + } + + @override + List get props => [ + id, + sourceType, + sourceAdoptionId, + sourceAccountSequence, + treeCount, + baseContribution, + distributionRate, + levelDepth, + bonusTier, + finalContribution, + effectiveDate, + expireDate, + isExpired, + createdAt, + ]; +} + +/// 贡献值记录分页响应 +class ContributionRecordsPage extends Equatable { + final List data; + final int total; + final int page; + final int pageSize; + + const ContributionRecordsPage({ + required this.data, + required this.total, + required this.page, + required this.pageSize, + }); + + bool get hasMore => page * pageSize < total; + + @override + List get props => [data, total, page, pageSize]; +} diff --git a/frontend/mining-app/lib/domain/repositories/contribution_repository.dart b/frontend/mining-app/lib/domain/repositories/contribution_repository.dart index ee260604..eaa2444f 100644 --- a/frontend/mining-app/lib/domain/repositories/contribution_repository.dart +++ b/frontend/mining-app/lib/domain/repositories/contribution_repository.dart @@ -1,7 +1,15 @@ import 'package:dartz/dartz.dart'; import '../../core/error/failures.dart'; import '../entities/contribution.dart'; +import '../entities/contribution_record.dart'; abstract class ContributionRepository { Future> getUserContribution(String accountSequence); + Future> getContributionRecords( + String accountSequence, { + ContributionSourceType? sourceType, + bool includeExpired = false, + int page = 1, + int pageSize = 50, + }); } 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 49ba8b41..9df1d4f9 100644 --- a/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart +++ b/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/utils/format_utils.dart'; +import '../../../domain/entities/contribution.dart'; +import '../../../domain/entities/contribution_record.dart'; import '../../providers/user_providers.dart'; import '../../providers/contribution_providers.dart'; @@ -21,6 +24,12 @@ class ContributionPage extends ConsumerWidget { final user = ref.watch(userNotifierProvider); final accountSequence = user.accountSequence ?? ''; final contributionAsync = ref.watch(contributionProvider(accountSequence)); + final recordsParams = ContributionRecordsParams( + accountSequence: accountSequence, + page: 1, + pageSize: 3, + ); + final recordsAsync = ref.watch(contributionRecordsProvider(recordsParams)); return Scaffold( backgroundColor: const Color(0xFFF5F5F5), @@ -28,6 +37,7 @@ class ContributionPage extends ConsumerWidget { child: RefreshIndicator( onRefresh: () async { ref.invalidate(contributionProvider(accountSequence)); + ref.invalidate(contributionRecordsProvider(recordsParams)); }, child: contributionAsync.when( data: (contribution) { @@ -50,13 +60,13 @@ class ContributionPage extends ConsumerWidget { _buildTodayEstimateCard(contribution), const SizedBox(height: 16), // 贡献值明细 - _buildContributionDetailCard(contribution), + _buildContributionDetailCard(context, ref, recordsAsync), const SizedBox(height: 16), // 团队层级统计 _buildTeamStatsCard(contribution), const SizedBox(height: 16), // 贡献值失效倒计时 - _buildExpirationCard(contribution), + _buildExpirationCard(contribution, recordsAsync), const SizedBox(height: 24), ]), ), @@ -144,7 +154,7 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildTotalContributionCard(contribution) { + Widget _buildTotalContributionCard(Contribution? contribution) { final total = contribution?.effectiveContribution ?? '0'; return Container( padding: const EdgeInsets.all(20), @@ -189,7 +199,7 @@ class ContributionPage extends ConsumerWidget { Icon(Icons.info_outline, size: 14, color: _grayText.withOpacity(0.7)), const SizedBox(width: 6), Text( - '贡献值有效期: 剩余 730 天', + '贡献值有效期: 730 天', style: TextStyle(fontSize: 12, color: _grayText.withOpacity(0.9)), ), ], @@ -200,7 +210,7 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildThreeColumnStats(contribution) { + Widget _buildThreeColumnStats(Contribution? contribution) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -240,7 +250,13 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildTodayEstimateCard(contribution) { + Widget _buildTodayEstimateCard(Contribution? contribution) { + // 基于贡献值计算预估收益(暂时显示占位符,后续可接入实际计算API) + final effectiveContribution = double.tryParse(contribution?.effectiveContribution ?? '0') ?? 0; + // 简单估算:假设每日发放总量为 10000 积分股,按贡献值占比分配 + // 这里先显示"--"表示暂无数据,后续可接入实际计算 + final hasContribution = effectiveContribution > 0; + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -279,22 +295,25 @@ class ContributionPage extends ConsumerWidget { // 收益数值 Column( crossAxisAlignment: CrossAxisAlignment.end, - children: const [ + children: [ Text.rich( TextSpan( children: [ TextSpan( - text: '+156.78 ', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _green), + text: hasContribution ? '计算中' : '--', + style: TextStyle( + fontSize: hasContribution ? 14 : 18, + fontWeight: FontWeight.bold, + color: _green, + ), ), - TextSpan( - text: '积分', + const TextSpan( + text: ' 积分股', style: TextStyle(fontSize: 12, color: _green), ), ], ), ), - Text('股', style: TextStyle(fontSize: 12, color: _green)), ], ), ], @@ -302,7 +321,11 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildContributionDetailCard(contribution) { + Widget _buildContributionDetailCard( + BuildContext context, + WidgetRef ref, + AsyncValue recordsAsync, + ) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -320,9 +343,11 @@ class ContributionPage extends ConsumerWidget { style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: _darkText), ), GestureDetector( - onTap: () {}, - child: Row( - children: const [ + onTap: () { + // TODO: 跳转到完整记录页面 + }, + child: const Row( + children: [ Text('查看全部', style: TextStyle(fontSize: 12, color: _orange)), Icon(Icons.chevron_right, size: 14, color: _orange), ], @@ -332,26 +357,68 @@ class ContributionPage extends ConsumerWidget { ), const SizedBox(height: 16), // 明细列表 - _buildDetailRow('认种榴莲树', '2024-01-15 14:30', '+22,617.00'), - const Divider(height: 24), - _buildDetailRow('团队奖励(5级)', '2024-01-15 09:12', '+1,130.85'), - const Divider(height: 24), - _buildDetailRow('直推奖励', '2024-01-14 18:45', '+565.43'), + recordsAsync.when( + data: (recordsPage) { + if (recordsPage == null || recordsPage.data.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Text( + '暂无贡献值记录', + style: TextStyle(fontSize: 14, color: _grayText), + ), + ); + } + return Column( + children: recordsPage.data.asMap().entries.map((entry) { + final index = entry.key; + final record = entry.value; + return Column( + children: [ + _buildDetailRow(record), + if (index < recordsPage.data.length - 1) const Divider(height: 24), + ], + ); + }).toList(), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (error, _) => Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + '加载失败', + style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)), + ), + ), + ), ], ), ); } - Widget _buildDetailRow(String title, String time, String amount) { + Widget _buildDetailRow(ContributionRecord record) { + final dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final formattedDate = dateFormat.format(record.createdAt); + final amount = '+${formatAmount(record.finalContribution)}'; + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _darkText)), + Text( + record.displayTitle, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _darkText), + ), const SizedBox(height: 2), - Text(time, style: const TextStyle(fontSize: 12, color: _grayText)), + Text(formattedDate, style: const TextStyle(fontSize: 12, color: _grayText)), ], ), Text( @@ -362,7 +429,7 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildTeamStatsCard(contribution) { + Widget _buildTeamStatsCard(Contribution? contribution) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -382,7 +449,7 @@ class ContributionPage extends ConsumerWidget { children: [ _buildTeamStatItem('直推人数', '${contribution?.directReferralAdoptedCount ?? 0}', '人'), const SizedBox(width: 16), - _buildTeamStatItem('团队总人数', '128', '人'), + _buildTeamStatItem('已解锁奖励', '${contribution?.unlockedBonusTiers ?? 0}', '档'), ], ), const SizedBox(height: 16), @@ -391,7 +458,7 @@ class ContributionPage extends ConsumerWidget { children: [ _buildTeamStatItem('已解锁层级', '${contribution?.unlockedLevelDepth ?? 0}', '级'), const SizedBox(width: 16), - _buildTeamStatItem('团队认种总数', '456', '棵'), + _buildTeamStatItem('是否认种', contribution?.hasAdopted == true ? '是' : '否', ''), ], ), ], @@ -419,10 +486,11 @@ class ContributionPage extends ConsumerWidget { text: '$value ', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _orange), ), - TextSpan( - text: unit, - style: const TextStyle(fontSize: 12, color: _grayText), - ), + if (unit.isNotEmpty) + TextSpan( + text: unit, + style: const TextStyle(fontSize: 12, color: _grayText), + ), ], ), ), @@ -432,7 +500,39 @@ class ContributionPage extends ConsumerWidget { ); } - Widget _buildExpirationCard(contribution) { + Widget _buildExpirationCard( + Contribution? contribution, + AsyncValue recordsAsync, + ) { + // 从记录中获取最近的过期日期 + DateTime? nearestExpireDate; + recordsAsync.whenData((recordsPage) { + if (recordsPage != null && recordsPage.data.isNotEmpty) { + // 找到未过期记录中最近的过期日期 + final activeRecords = recordsPage.data.where((r) => !r.isExpired).toList(); + if (activeRecords.isNotEmpty) { + activeRecords.sort((a, b) => a.expireDate.compareTo(b.expireDate)); + nearestExpireDate = activeRecords.first.expireDate; + } + } + }); + + // 计算剩余天数和进度 + final now = DateTime.now(); + int daysRemaining = 730; // 默认值 + double progress = 1.0; + String expireDateText = '暂无过期信息'; + + if (nearestExpireDate != null) { + daysRemaining = nearestExpireDate!.difference(now).inDays; + if (daysRemaining < 0) daysRemaining = 0; + // 假设总有效期为730天 + progress = daysRemaining / 730; + if (progress > 1) progress = 1; + if (progress < 0) progress = 0; + expireDateText = '您的贡献值将于 ${DateFormat('yyyy-MM-dd').format(nearestExpireDate!)} 失效'; + } + return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( @@ -443,8 +543,8 @@ class ContributionPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题 - Row( - children: const [ + const Row( + children: [ Icon(Icons.timer_outlined, color: _orange, size: 24), SizedBox(width: 8), Text( @@ -458,7 +558,7 @@ class ContributionPage extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(5), child: LinearProgressIndicator( - value: 0.8, + value: progress, minHeight: 10, backgroundColor: _bgGray, valueColor: const AlwaysStoppedAnimation(_orange), @@ -466,9 +566,14 @@ class ContributionPage extends ConsumerWidget { ), const SizedBox(height: 12), // 说明文字 - const Text( - '您的贡献值将于 2026-01-15 失效', - style: TextStyle(fontSize: 12, color: _grayText), + Text( + expireDateText, + style: const TextStyle(fontSize: 12, color: _grayText), + ), + const SizedBox(height: 4), + Text( + '剩余 $daysRemaining 天', + style: const TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), ), const SizedBox(height: 8), // 提示 diff --git a/frontend/mining-app/lib/presentation/providers/contribution_providers.dart b/frontend/mining-app/lib/presentation/providers/contribution_providers.dart index 0ea81c48..87a26685 100644 --- a/frontend/mining-app/lib/presentation/providers/contribution_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/contribution_providers.dart @@ -1,12 +1,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/contribution.dart'; +import '../../domain/entities/contribution_record.dart'; import '../../domain/usecases/contribution/get_user_contribution.dart'; +import '../../domain/repositories/contribution_repository.dart'; import '../../core/di/injection.dart'; final getUserContributionUseCaseProvider = Provider((ref) { return getIt(); }); +final contributionRepositoryProvider = Provider((ref) { + return getIt(); +}); + final contributionProvider = FutureProvider.family( (ref, accountSequence) async { final useCase = ref.watch(getUserContributionUseCaseProvider); @@ -17,3 +23,44 @@ final contributionProvider = FutureProvider.family( ); }, ); + +/// 贡献值记录请求参数 +class ContributionRecordsParams { + final String accountSequence; + final int page; + final int pageSize; + + const ContributionRecordsParams({ + required this.accountSequence, + this.page = 1, + this.pageSize = 10, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ContributionRecordsParams && + runtimeType == other.runtimeType && + accountSequence == other.accountSequence && + page == other.page && + pageSize == other.pageSize; + + @override + int get hashCode => accountSequence.hashCode ^ page.hashCode ^ pageSize.hashCode; +} + +/// 贡献值记录 Provider +final contributionRecordsProvider = FutureProvider.family( + (ref, params) async { + final repository = ref.watch(contributionRepositoryProvider); + final result = await repository.getContributionRecords( + params.accountSequence, + page: params.page, + pageSize: params.pageSize, + ); + return result.fold( + (failure) => throw Exception(failure.message), + (records) => records, + ); + }, +); diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index 09976a34..212d66f6 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -214,29 +214,6 @@ class UserNotifier extends StateNotifier { } } - // Legacy method for compatibility - void login({ - required String accountSequence, - String? nickname, - String? phone, - }) { - state = state.copyWith( - accountSequence: accountSequence, - nickname: nickname, - phone: phone, - isLoggedIn: true, - ); - } - - // Legacy mock method for development - void setMockUser() { - state = state.copyWith( - accountSequence: '1001', - nickname: '测试用户', - phone: '138****8888', - isLoggedIn: true, - ); - } } final userNotifierProvider = StateNotifierProvider(