From 546c0060da7a49fb3447ae027dc2135f2d9288fb Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 14 Jan 2026 23:23:31 -0800 Subject: [PATCH] feat(mining-app): add mining records and planting records pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mining records page showing distribution history with share amounts - Add planting records page with adoption summary and detailed records - Remove 推广奖励 and 收益明细 from profile page - Add planting-ledger API endpoint and data models Co-Authored-By: Claude Opus 4.5 --- .../lib/core/network/api_endpoints.dart | 4 + .../lib/core/router/app_router.dart | 10 + .../mining-app/lib/core/router/routes.dart | 1 + .../remote/mining_remote_datasource.dart | 48 +- .../data/models/planting_record_model.dart | 101 ++++ .../repositories/mining_repository_impl.dart | 28 +- .../lib/domain/entities/planting_record.dart | 132 +++++ .../repositories/mining_repository.dart | 28 +- .../pages/profile/mining_records_page.dart | 346 ++++++++++++ .../pages/profile/planting_records_page.dart | 496 ++++++++++++++++++ .../pages/profile/profile_page.dart | 14 +- .../providers/mining_providers.dart | 110 ++++ 12 files changed, 1293 insertions(+), 25 deletions(-) create mode 100644 frontend/mining-app/lib/data/models/planting_record_model.dart create mode 100644 frontend/mining-app/lib/domain/entities/planting_record.dart create mode 100644 frontend/mining-app/lib/presentation/pages/profile/mining_records_page.dart create mode 100644 frontend/mining-app/lib/presentation/pages/profile/planting_records_page.dart diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 2b0f73bd..99ec77e2 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -55,4 +55,8 @@ class ApiEndpoints { '/api/v2/contribution/accounts/$accountSequence'; static String contributionRecords(String accountSequence) => '/api/v2/contribution/accounts/$accountSequence/records'; + + // User Service 2.0 (Kong路由: /api/v2/users) + static String plantingLedger(String accountSequence) => + '/api/v2/users/$accountSequence/planting-ledger'; } diff --git a/frontend/mining-app/lib/core/router/app_router.dart b/frontend/mining-app/lib/core/router/app_router.dart index 5c89ca3d..fe5052d2 100644 --- a/frontend/mining-app/lib/core/router/app_router.dart +++ b/frontend/mining-app/lib/core/router/app_router.dart @@ -11,6 +11,8 @@ import '../../presentation/pages/contribution/contribution_records_page.dart'; import '../../presentation/pages/trading/trading_page.dart'; import '../../presentation/pages/asset/asset_page.dart'; import '../../presentation/pages/profile/profile_page.dart'; +import '../../presentation/pages/profile/mining_records_page.dart'; +import '../../presentation/pages/profile/planting_records_page.dart'; import '../../presentation/widgets/main_shell.dart'; import '../../presentation/providers/user_providers.dart'; import 'routes.dart'; @@ -102,6 +104,14 @@ final appRouterProvider = Provider((ref) { path: Routes.contributionRecords, builder: (context, state) => const ContributionRecordsListPage(), ), + GoRoute( + path: Routes.miningRecords, + builder: (context, state) => const MiningRecordsPage(), + ), + GoRoute( + path: Routes.plantingRecords, + builder: (context, state) => const PlantingRecordsPage(), + ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/frontend/mining-app/lib/core/router/routes.dart b/frontend/mining-app/lib/core/router/routes.dart index ef0c71bd..e0efff18 100644 --- a/frontend/mining-app/lib/core/router/routes.dart +++ b/frontend/mining-app/lib/core/router/routes.dart @@ -10,5 +10,6 @@ class Routes { static const String profile = '/profile'; static const String miningRecords = '/mining-records'; static const String contributionRecords = '/contribution-records'; + static const String plantingRecords = '/planting-records'; static const String orders = '/orders'; } diff --git a/frontend/mining-app/lib/data/datasources/remote/mining_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/mining_remote_datasource.dart index 3467b01f..77b3aa5e 100644 --- a/frontend/mining-app/lib/data/datasources/remote/mining_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/mining_remote_datasource.dart @@ -1,18 +1,25 @@ import '../../models/share_account_model.dart'; import '../../models/mining_record_model.dart'; import '../../models/global_state_model.dart'; +import '../../models/planting_record_model.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; import '../../../core/error/exceptions.dart'; +import '../../../domain/repositories/mining_repository.dart'; abstract class MiningRemoteDataSource { Future getShareAccount(String accountSequence); - Future> getMiningRecords( + Future getMiningRecords( String accountSequence, { int page = 1, - int limit = 20, + int pageSize = 20, }); Future getGlobalState(); + Future getPlantingLedger( + String accountSequence, { + int page = 1, + int pageSize = 10, + }); } class MiningRemoteDataSourceImpl implements MiningRemoteDataSource { @@ -31,18 +38,28 @@ class MiningRemoteDataSourceImpl implements MiningRemoteDataSource { } @override - Future> getMiningRecords( + Future getMiningRecords( String accountSequence, { int page = 1, - int limit = 20, + int pageSize = 20, }) async { try { final response = await client.get( ApiEndpoints.miningRecords(accountSequence), - queryParameters: {'page': page, 'pageSize': limit}, + queryParameters: {'page': page, 'pageSize': pageSize}, + ); + final data = response.data; + final items = (data['items'] as List? ?? data['records'] as List? ?? []) + .map((json) => MiningRecordModel.fromJson(json)) + .toList(); + final pagination = data['pagination'] ?? {}; + return MiningRecordsPage( + items: items, + total: pagination['total'] ?? items.length, + page: pagination['page'] ?? page, + pageSize: pagination['pageSize'] ?? pageSize, + totalPages: pagination['totalPages'] ?? 1, ); - final items = response.data['items'] as List? ?? []; - return items.map((json) => MiningRecordModel.fromJson(json)).toList(); } catch (e) { throw ServerException(e.toString()); } @@ -57,4 +74,21 @@ class MiningRemoteDataSourceImpl implements MiningRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future getPlantingLedger( + String accountSequence, { + int page = 1, + int pageSize = 10, + }) async { + try { + final response = await client.get( + ApiEndpoints.plantingLedger(accountSequence), + queryParameters: {'page': page, 'pageSize': pageSize}, + ); + return PlantingLedgerPageModel.fromJson(response.data); + } catch (e) { + throw ServerException(e.toString()); + } + } } diff --git a/frontend/mining-app/lib/data/models/planting_record_model.dart b/frontend/mining-app/lib/data/models/planting_record_model.dart new file mode 100644 index 00000000..ef4221bd --- /dev/null +++ b/frontend/mining-app/lib/data/models/planting_record_model.dart @@ -0,0 +1,101 @@ +import '../../domain/entities/planting_record.dart'; + +class PlantingRecordModel extends PlantingRecord { + const PlantingRecordModel({ + required super.orderId, + required super.orderNo, + super.originalAdoptionId, + required super.treeCount, + required super.contributionPerTree, + required super.totalContribution, + required super.totalAmount, + required super.status, + super.adoptionDate, + required super.createdAt, + }); + + factory PlantingRecordModel.fromJson(Map json) { + return PlantingRecordModel( + orderId: json['orderId'] ?? json['id'] ?? '', + orderNo: json['orderNo'] ?? '', + originalAdoptionId: json['originalAdoptionId'], + treeCount: json['treeCount'] ?? 0, + contributionPerTree: json['contributionPerTree']?.toString() ?? '0', + totalContribution: json['totalContribution']?.toString() ?? json['totalAmount']?.toString() ?? '0', + totalAmount: json['totalAmount']?.toString() ?? '0', + status: _parseStatus(json['status']), + adoptionDate: json['adoptionDate'] != null ? DateTime.tryParse(json['adoptionDate']) : null, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + ); + } + + static PlantingStatus _parseStatus(String? status) { + switch (status?.toUpperCase()) { + case 'CREATED': + return PlantingStatus.created; + case 'PAID': + return PlantingStatus.paid; + case 'FUND_ALLOCATED': + return PlantingStatus.fundAllocated; + case 'MINING_ENABLED': + return PlantingStatus.miningEnabled; + case 'CANCELLED': + return PlantingStatus.cancelled; + case 'EXPIRED': + return PlantingStatus.expired; + default: + return PlantingStatus.created; + } + } +} + +class PlantingSummaryModel extends PlantingSummary { + const PlantingSummaryModel({ + required super.totalOrders, + required super.totalTreeCount, + required super.totalAmount, + required super.effectiveTreeCount, + super.firstPlantingAt, + super.lastPlantingAt, + }); + + factory PlantingSummaryModel.fromJson(Map json) { + return PlantingSummaryModel( + totalOrders: json['totalOrders'] ?? 0, + totalTreeCount: json['totalTreeCount'] ?? 0, + totalAmount: json['totalAmount']?.toString() ?? '0', + effectiveTreeCount: json['effectiveTreeCount'] ?? 0, + firstPlantingAt: json['firstPlantingAt'] != null + ? DateTime.tryParse(json['firstPlantingAt']) + : null, + lastPlantingAt: json['lastPlantingAt'] != null + ? DateTime.tryParse(json['lastPlantingAt']) + : null, + ); + } +} + +class PlantingLedgerPageModel extends PlantingLedgerPage { + const PlantingLedgerPageModel({ + required super.summary, + required super.items, + required super.total, + required super.page, + required super.pageSize, + required super.totalPages, + }); + + factory PlantingLedgerPageModel.fromJson(Map json) { + final itemsList = json['items'] as List? ?? []; + return PlantingLedgerPageModel( + summary: PlantingSummaryModel.fromJson(json['summary'] ?? {}), + items: itemsList.map((e) => PlantingRecordModel.fromJson(e)).toList(), + total: json['total'] ?? 0, + page: json['page'] ?? 1, + pageSize: json['pageSize'] ?? 10, + totalPages: json['totalPages'] ?? 0, + ); + } +} diff --git a/frontend/mining-app/lib/data/repositories/mining_repository_impl.dart b/frontend/mining-app/lib/data/repositories/mining_repository_impl.dart index 397be7f8..ea7b4b61 100644 --- a/frontend/mining-app/lib/data/repositories/mining_repository_impl.dart +++ b/frontend/mining-app/lib/data/repositories/mining_repository_impl.dart @@ -1,7 +1,7 @@ import 'package:dartz/dartz.dart'; import '../../domain/entities/share_account.dart'; -import '../../domain/entities/mining_record.dart'; import '../../domain/entities/global_state.dart'; +import '../../domain/entities/planting_record.dart'; import '../../domain/repositories/mining_repository.dart'; import '../../core/error/exceptions.dart'; import '../../core/error/failures.dart'; @@ -30,16 +30,16 @@ class MiningRepositoryImpl implements MiningRepository { } @override - Future>> getMiningRecords( + Future> getMiningRecords( String accountSequence, { int page = 1, - int limit = 20, + int pageSize = 20, }) async { try { final result = await remoteDataSource.getMiningRecords( accountSequence, page: page, - limit: limit, + pageSize: pageSize, ); return Right(result); } on ServerException catch (e) { @@ -70,4 +70,24 @@ class MiningRepositoryImpl implements MiningRepository { return Left(const NetworkFailure()); } } + + @override + Future> getPlantingLedger( + String accountSequence, { + int page = 1, + int pageSize = 10, + }) async { + try { + final result = await remoteDataSource.getPlantingLedger( + accountSequence, + 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/planting_record.dart b/frontend/mining-app/lib/domain/entities/planting_record.dart new file mode 100644 index 00000000..f86a3266 --- /dev/null +++ b/frontend/mining-app/lib/domain/entities/planting_record.dart @@ -0,0 +1,132 @@ +import 'package:equatable/equatable.dart'; + +/// 认种状态枚举 +enum PlantingStatus { + created, + paid, + fundAllocated, + miningEnabled, + cancelled, + expired, +} + +/// 认种记录 +class PlantingRecord extends Equatable { + /// 订单ID + final String orderId; + /// 订单号 + final String orderNo; + /// 原始认种ID + final String? originalAdoptionId; + /// 认种数量 + final int treeCount; + /// 单棵算力 + final String contributionPerTree; + /// 总算力 + final String totalContribution; + /// 认种金额 + final String totalAmount; + /// 状态 + final PlantingStatus status; + /// 认种日期 + final DateTime? adoptionDate; + /// 创建时间 + final DateTime createdAt; + + const PlantingRecord({ + required this.orderId, + required this.orderNo, + this.originalAdoptionId, + required this.treeCount, + required this.contributionPerTree, + required this.totalContribution, + required this.totalAmount, + required this.status, + this.adoptionDate, + required this.createdAt, + }); + + /// 状态显示文本 + String get statusText { + switch (status) { + case PlantingStatus.created: + return '已创建'; + case PlantingStatus.paid: + return '已支付'; + case PlantingStatus.fundAllocated: + return '资金已分配'; + case PlantingStatus.miningEnabled: + return '已开始挖矿'; + case PlantingStatus.cancelled: + return '已取消'; + case PlantingStatus.expired: + return '已过期'; + } + } + + /// 是否为有效状态 + bool get isActive => + status == PlantingStatus.miningEnabled || + status == PlantingStatus.paid || + status == PlantingStatus.fundAllocated; + + @override + List get props => [orderId, orderNo, treeCount, status]; +} + +/// 认种汇总 +class PlantingSummary extends Equatable { + /// 总订单数 + final int totalOrders; + /// 总认种量 + final int totalTreeCount; + /// 总金额 + final String totalAmount; + /// 有效认种量 + final int effectiveTreeCount; + /// 首次认种时间 + final DateTime? firstPlantingAt; + /// 最近认种时间 + final DateTime? lastPlantingAt; + + const PlantingSummary({ + required this.totalOrders, + required this.totalTreeCount, + required this.totalAmount, + required this.effectiveTreeCount, + this.firstPlantingAt, + this.lastPlantingAt, + }); + + @override + List get props => [ + totalOrders, + totalTreeCount, + totalAmount, + effectiveTreeCount, + firstPlantingAt, + lastPlantingAt, + ]; +} + +/// 认种分类账分页数据 +class PlantingLedgerPage extends Equatable { + final PlantingSummary summary; + final List items; + final int total; + final int page; + final int pageSize; + final int totalPages; + + const PlantingLedgerPage({ + required this.summary, + required this.items, + required this.total, + required this.page, + required this.pageSize, + required this.totalPages, + }); + + @override + List get props => [summary, items, total, page, pageSize, totalPages]; +} diff --git a/frontend/mining-app/lib/domain/repositories/mining_repository.dart b/frontend/mining-app/lib/domain/repositories/mining_repository.dart index ca320ad7..36ed2534 100644 --- a/frontend/mining-app/lib/domain/repositories/mining_repository.dart +++ b/frontend/mining-app/lib/domain/repositories/mining_repository.dart @@ -3,15 +3,39 @@ import '../../core/error/failures.dart'; import '../entities/share_account.dart'; import '../entities/mining_record.dart'; import '../entities/global_state.dart'; +import '../entities/planting_record.dart'; + +/// 挖矿记录分页数据 +class MiningRecordsPage { + final List items; + final int total; + final int page; + final int pageSize; + final int totalPages; + + const MiningRecordsPage({ + required this.items, + required this.total, + required this.page, + required this.pageSize, + required this.totalPages, + }); +} abstract class MiningRepository { Future> getShareAccount(String accountSequence); - Future>> getMiningRecords( + Future> getMiningRecords( String accountSequence, { int page = 1, - int limit = 20, + int pageSize = 20, }); Future> getGlobalState(); + + Future> getPlantingLedger( + String accountSequence, { + int page = 1, + int pageSize = 10, + }); } diff --git a/frontend/mining-app/lib/presentation/pages/profile/mining_records_page.dart b/frontend/mining-app/lib/presentation/pages/profile/mining_records_page.dart new file mode 100644 index 00000000..5a6399a6 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/profile/mining_records_page.dart @@ -0,0 +1,346 @@ +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/mining_record.dart'; +import '../../../domain/repositories/mining_repository.dart'; +import '../../providers/user_providers.dart'; +import '../../providers/mining_providers.dart'; + +/// 分配记录页面(挖矿记录) +class MiningRecordsPage extends ConsumerStatefulWidget { + const MiningRecordsPage({super.key}); + + @override + ConsumerState createState() => _MiningRecordsPageState(); +} + +class _MiningRecordsPageState extends ConsumerState { + static const Color _orange = Color(0xFFFF6B00); + static const Color _green = Color(0xFF22C55E); + static const Color _grayText = Color(0xFF6B7280); + static const Color _darkText = Color(0xFF1F2937); + static const Color _bgGray = Color(0xFFF3F4F6); + + int _currentPage = 1; + static const int _pageSize = 20; + + @override + Widget build(BuildContext context) { + final user = ref.watch(userNotifierProvider); + final accountSequence = user.accountSequence ?? ''; + + final recordsParams = MiningRecordsParams( + accountSequence: accountSequence, + page: _currentPage, + pageSize: _pageSize, + ); + final recordsAsync = ref.watch(miningRecordsProvider(recordsParams)); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: _darkText, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + '分配记录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _darkText, + ), + ), + centerTitle: true, + ), + body: RefreshIndicator( + onRefresh: () async { + setState(() => _currentPage = 1); + ref.invalidate(miningRecordsProvider(recordsParams)); + }, + child: recordsAsync.when( + loading: () => _buildLoadingList(), + error: (error, stack) => _buildErrorView(error.toString(), recordsParams), + data: (recordsPage) { + if (recordsPage == null || recordsPage.items.isEmpty) { + return _buildEmptyView(); + } + return _buildRecordsList(recordsPage); + }, + ), + ), + ); + } + + Widget _buildLoadingList() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 10, + itemBuilder: (context, index) => _buildShimmerCard(), + ); + } + + Widget _buildShimmerCard() { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 120, + height: 16, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + Container( + width: 80, + height: 20, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + width: 60, + height: 12, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 16), + Container( + width: 80, + height: 12, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildErrorView(String error, MiningRecordsParams params) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.error), + const SizedBox(height: 16), + Text('加载失败', style: TextStyle(fontSize: 16, color: _grayText)), + const SizedBox(height: 8), + Text(error, style: TextStyle(fontSize: 12, color: _grayText)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.invalidate(miningRecordsProvider(params)); + }, + style: ElevatedButton.styleFrom(backgroundColor: _orange), + child: const Text('重试', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + Widget _buildEmptyView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: _grayText.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + '暂无分配记录', + style: TextStyle(fontSize: 16, color: _grayText), + ), + const SizedBox(height: 8), + Text( + '认种榴莲树后将开始产生收益', + style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)), + ), + ], + ), + ); + } + + Widget _buildRecordsList(MiningRecordsPage recordsPage) { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: recordsPage.items.length + 1, + itemBuilder: (context, index) { + if (index == recordsPage.items.length) { + return _buildPaginationInfo(recordsPage); + } + return _buildRecordCard(recordsPage.items[index]); + }, + ); + } + + Widget _buildRecordCard(MiningRecord record) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:分配时间 + 获得积分股 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + record.distributionMinute, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: _darkText, + fontFamily: 'monospace', + ), + ), + Text( + '+${formatAmount(record.shareAmount)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _green, + ), + ), + ], + ), + const SizedBox(height: 12), + + // 第二行:算力占比 + 价格快照 + Row( + children: [ + _buildInfoItem('算力占比', _formatPercent(record.contributionRatio)), + const SizedBox(width: 24), + _buildInfoItem('价格快照', _formatPrice(record.priceSnapshot)), + ], + ), + const SizedBox(height: 8), + + // 第三行:记录时间 + Row( + children: [ + Icon(Icons.access_time, size: 12, color: _grayText.withOpacity(0.7)), + const SizedBox(width: 4), + Text( + DateFormat('yyyy-MM-dd HH:mm:ss').format(record.createdAt), + style: TextStyle(fontSize: 11, color: _grayText.withOpacity(0.7)), + ), + ], + ), + ], + ), + ); + } + + Widget _buildInfoItem(String label, String value) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle(fontSize: 12, color: _grayText.withOpacity(0.7)), + ), + Text( + value, + style: const TextStyle( + fontSize: 12, + color: _darkText, + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + ), + ), + ], + ); + } + + String _formatPercent(String ratio) { + try { + final value = double.parse(ratio); + return '${(value * 100).toStringAsFixed(6)}%'; + } catch (e) { + return ratio; + } + } + + String _formatPrice(String price) { + try { + final value = double.parse(price); + return value.toStringAsFixed(8); + } catch (e) { + return price; + } + } + + Widget _buildPaginationInfo(MiningRecordsPage recordsPage) { + final totalPages = recordsPage.totalPages > 0 + ? recordsPage.totalPages + : (recordsPage.total / _pageSize).ceil(); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Text( + '共 ${recordsPage.total} 条记录,第 $_currentPage / $totalPages 页', + style: TextStyle(fontSize: 12, color: _grayText), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_currentPage > 1) + TextButton.icon( + onPressed: () { + setState(() => _currentPage--); + }, + icon: const Icon(Icons.chevron_left, size: 18), + label: const Text('上一页'), + style: TextButton.styleFrom(foregroundColor: _orange), + ), + const SizedBox(width: 16), + if (_currentPage < totalPages) + TextButton.icon( + onPressed: () { + setState(() => _currentPage++); + }, + icon: const Text('下一页'), + label: const Icon(Icons.chevron_right, size: 18), + style: TextButton.styleFrom(foregroundColor: _orange), + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/profile/planting_records_page.dart b/frontend/mining-app/lib/presentation/pages/profile/planting_records_page.dart new file mode 100644 index 00000000..47581f28 --- /dev/null +++ b/frontend/mining-app/lib/presentation/pages/profile/planting_records_page.dart @@ -0,0 +1,496 @@ +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/planting_record.dart'; +import '../../providers/user_providers.dart'; +import '../../providers/mining_providers.dart'; + +/// 认种记录页面 +class PlantingRecordsPage extends ConsumerStatefulWidget { + const PlantingRecordsPage({super.key}); + + @override + ConsumerState createState() => _PlantingRecordsPageState(); +} + +class _PlantingRecordsPageState extends ConsumerState { + static const Color _orange = Color(0xFFFF6B00); + static const Color _green = Color(0xFF22C55E); + static const Color _blue = Color(0xFF3B82F6); + static const Color _grayText = Color(0xFF6B7280); + static const Color _darkText = Color(0xFF1F2937); + static const Color _bgGray = Color(0xFFF3F4F6); + + int _currentPage = 1; + static const int _pageSize = 10; + + @override + Widget build(BuildContext context) { + final user = ref.watch(userNotifierProvider); + final accountSequence = user.accountSequence ?? ''; + + final recordsParams = PlantingRecordsParams( + accountSequence: accountSequence, + page: _currentPage, + pageSize: _pageSize, + ); + final recordsAsync = ref.watch(plantingRecordsProvider(recordsParams)); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: _darkText, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + '认种记录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _darkText, + ), + ), + centerTitle: true, + ), + body: RefreshIndicator( + onRefresh: () async { + setState(() => _currentPage = 1); + ref.invalidate(plantingRecordsProvider(recordsParams)); + }, + child: recordsAsync.when( + loading: () => _buildLoadingView(), + error: (error, stack) => _buildErrorView(error.toString(), recordsParams), + data: (ledgerPage) { + if (ledgerPage == null || ledgerPage.items.isEmpty) { + return _buildEmptyView(); + } + return _buildContent(ledgerPage); + }, + ), + ), + ); + } + + Widget _buildLoadingView() { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + // 汇总卡片骨架 + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(3, (index) => _buildShimmerStat()), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(3, (index) => _buildShimmerStat()), + ), + ], + ), + ), + // 列表骨架 + ...List.generate(5, (index) => _buildShimmerCard()), + ], + ), + ); + } + + Widget _buildShimmerStat() { + return Column( + children: [ + Container( + width: 60, + height: 24, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + width: 40, + height: 12, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ); + } + + Widget _buildShimmerCard() { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container(width: 100, height: 16, color: Colors.grey[300]), + Container(width: 60, height: 20, color: Colors.grey[300]), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container(width: 80, height: 12, color: Colors.grey[300]), + const SizedBox(width: 16), + Container(width: 80, height: 12, color: Colors.grey[300]), + ], + ), + ], + ), + ); + } + + Widget _buildErrorView(String error, PlantingRecordsParams params) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.error), + const SizedBox(height: 16), + Text('加载失败', style: TextStyle(fontSize: 16, color: _grayText)), + const SizedBox(height: 8), + Text(error, style: TextStyle(fontSize: 12, color: _grayText)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.invalidate(plantingRecordsProvider(params)); + }, + style: ElevatedButton.styleFrom(backgroundColor: _orange), + child: const Text('重试', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + Widget _buildEmptyView() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.eco_outlined, size: 64, color: _grayText.withOpacity(0.5)), + const SizedBox(height: 16), + Text( + '暂无认种记录', + style: TextStyle(fontSize: 16, color: _grayText), + ), + const SizedBox(height: 8), + Text( + '认种榴莲树后将显示记录', + style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)), + ), + ], + ), + ); + } + + Widget _buildContent(PlantingLedgerPage ledgerPage) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + // 汇总卡片 + _buildSummaryCard(ledgerPage.summary), + // 记录列表 + ...ledgerPage.items.map((record) => _buildRecordCard(record)), + // 分页信息 + _buildPaginationInfo(ledgerPage), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _buildSummaryCard(PlantingSummary summary) { + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.eco, size: 20, color: _green), + const SizedBox(width: 8), + const Text( + '认种汇总', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _darkText, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSummaryItem('总订单数', summary.totalOrders.toString()), + _buildSummaryItem('总认种量', summary.totalTreeCount.toString(), color: _green), + _buildSummaryItem('总金额', formatAmount(summary.totalAmount), color: _orange), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildSummaryItem('有效认种', summary.effectiveTreeCount.toString(), color: _blue), + _buildSummaryItem( + '首次认种', + summary.firstPlantingAt != null + ? DateFormat('MM-dd').format(summary.firstPlantingAt!) + : '-', + ), + _buildSummaryItem( + '最近认种', + summary.lastPlantingAt != null + ? DateFormat('MM-dd').format(summary.lastPlantingAt!) + : '-', + ), + ], + ), + ], + ), + ); + } + + Widget _buildSummaryItem(String label, String value, {Color? color}) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color ?? _darkText, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(fontSize: 12, color: _grayText), + ), + ], + ); + } + + Widget _buildRecordCard(PlantingRecord record) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: !record.isActive + ? Border.all(color: Colors.grey.withOpacity(0.3)) + : null, + ), + child: Opacity( + opacity: record.isActive ? 1.0 : 0.6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:订单号 + 状态 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + record.originalAdoptionId ?? record.orderNo, + style: const TextStyle( + fontSize: 13, + color: _darkText, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _buildStatusBadge(record.status), + ], + ), + const SizedBox(height: 12), + + // 第二行:认种数量 + 单棵算力 + 总算力 + Row( + children: [ + _buildInfoItem('认种数量', '${record.treeCount}棵'), + const SizedBox(width: 16), + _buildInfoItem('单棵算力', formatAmount(record.contributionPerTree)), + const SizedBox(width: 16), + _buildInfoItem('总算力', formatAmount(record.totalContribution), isHighlight: true), + ], + ), + const SizedBox(height: 8), + + // 第三行:认种日期 + Row( + children: [ + Icon(Icons.calendar_today_outlined, size: 12, color: _grayText.withOpacity(0.7)), + const SizedBox(width: 4), + Text( + record.adoptionDate != null + ? DateFormat('yyyy-MM-dd HH:mm').format(record.adoptionDate!) + : DateFormat('yyyy-MM-dd HH:mm').format(record.createdAt), + style: TextStyle(fontSize: 11, color: _grayText.withOpacity(0.7)), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusBadge(PlantingStatus status) { + Color bgColor; + Color textColor; + + switch (status) { + case PlantingStatus.miningEnabled: + bgColor = _green.withOpacity(0.1); + textColor = _green; + break; + case PlantingStatus.paid: + case PlantingStatus.fundAllocated: + bgColor = _blue.withOpacity(0.1); + textColor = _blue; + break; + case PlantingStatus.created: + bgColor = _orange.withOpacity(0.1); + textColor = _orange; + break; + case PlantingStatus.cancelled: + case PlantingStatus.expired: + bgColor = Colors.red.withOpacity(0.1); + textColor = Colors.red; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status.toString().split('.').last == 'miningEnabled' ? '已开始挖矿' : _getStatusText(status), + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: textColor), + ), + ); + } + + String _getStatusText(PlantingStatus status) { + switch (status) { + case PlantingStatus.created: + return '已创建'; + case PlantingStatus.paid: + return '已支付'; + case PlantingStatus.fundAllocated: + return '资金已分配'; + case PlantingStatus.miningEnabled: + return '已开始挖矿'; + case PlantingStatus.cancelled: + return '已取消'; + case PlantingStatus.expired: + return '已过期'; + } + } + + Widget _buildInfoItem(String label, String value, {bool isHighlight = false}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle(fontSize: 12, color: _grayText.withOpacity(0.7)), + ), + Text( + value, + style: TextStyle( + fontSize: 12, + color: isHighlight ? _green : _darkText, + fontWeight: isHighlight ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ); + } + + Widget _buildPaginationInfo(PlantingLedgerPage ledgerPage) { + final totalPages = ledgerPage.totalPages > 0 + ? ledgerPage.totalPages + : (ledgerPage.total / _pageSize).ceil(); + + if (totalPages <= 1) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Text( + '共 ${ledgerPage.total} 条记录,第 $_currentPage / $totalPages 页', + style: TextStyle(fontSize: 12, color: _grayText), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_currentPage > 1) + TextButton.icon( + onPressed: () { + setState(() => _currentPage--); + }, + icon: const Icon(Icons.chevron_left, size: 18), + label: const Text('上一页'), + style: TextButton.styleFrom(foregroundColor: _orange), + ), + const SizedBox(width: 16), + if (_currentPage < totalPages) + TextButton.icon( + onPressed: () { + setState(() => _currentPage++); + }, + icon: const Text('下一页'), + label: const Icon(Icons.chevron_right, size: 18), + style: TextButton.styleFrom(foregroundColor: _orange), + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart index d1b5aed7..d4c3e321 100644 --- a/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart +++ b/frontend/mining-app/lib/presentation/pages/profile/profile_page.dart @@ -410,12 +410,12 @@ class ProfilePage extends ConsumerWidget { _buildRecordIcon( icon: Icons.eco, label: '认种记录', - onTap: () {}, + onTap: () => context.push(Routes.plantingRecords), ), _buildRecordIcon( icon: Icons.assignment, label: '分配记录', - onTap: () {}, + onTap: () => context.push(Routes.miningRecords), ), _buildRecordIcon( icon: Icons.receipt_long, @@ -491,16 +491,6 @@ class ProfilePage extends ConsumerWidget { icon: Icons.people, label: '我的团队', onTap: () {}, - ), - _buildSettingItem( - icon: Icons.trending_up, - label: '收益明细', - onTap: () {}, - ), - _buildSettingItem( - icon: Icons.card_giftcard, - label: '推广奖励', - onTap: () {}, showDivider: false, ), ], diff --git a/frontend/mining-app/lib/presentation/providers/mining_providers.dart b/frontend/mining-app/lib/presentation/providers/mining_providers.dart index 26ab9ec7..9de0c9cb 100644 --- a/frontend/mining-app/lib/presentation/providers/mining_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/mining_providers.dart @@ -2,10 +2,16 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/share_account.dart'; import '../../domain/entities/global_state.dart'; +import '../../domain/entities/planting_record.dart'; +import '../../domain/repositories/mining_repository.dart'; import '../../domain/usecases/mining/get_share_account.dart'; import '../../domain/usecases/mining/get_global_state.dart'; import '../../core/di/injection.dart'; +final miningRepositoryProvider = Provider((ref) { + return getIt(); +}); + // Use Cases Providers final getShareAccountUseCaseProvider = Provider((ref) { return getIt(); @@ -67,3 +73,107 @@ final currentPriceProvider = Provider((ref) { error: (_, __) => '0', ); }); + +/// 挖矿记录请求参数 +class MiningRecordsParams { + final String accountSequence; + final int page; + final int pageSize; + + const MiningRecordsParams({ + required this.accountSequence, + this.page = 1, + this.pageSize = 10, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MiningRecordsParams && + runtimeType == other.runtimeType && + accountSequence == other.accountSequence && + page == other.page && + pageSize == other.pageSize; + + @override + int get hashCode => accountSequence.hashCode ^ page.hashCode ^ pageSize.hashCode; +} + +/// 挖矿记录 Provider +final miningRecordsProvider = FutureProvider.family( + (ref, params) async { + if (params.accountSequence.isEmpty) { + return null; + } + + final repository = ref.watch(miningRepositoryProvider); + final result = await repository.getMiningRecords( + params.accountSequence, + page: params.page, + pageSize: params.pageSize, + ); + + ref.keepAlive(); + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + + return result.fold( + (failure) => throw Exception(failure.message), + (records) => records, + ); + }, +); + +/// 认种记录请求参数 +class PlantingRecordsParams { + final String accountSequence; + final int page; + final int pageSize; + + const PlantingRecordsParams({ + required this.accountSequence, + this.page = 1, + this.pageSize = 10, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlantingRecordsParams && + runtimeType == other.runtimeType && + accountSequence == other.accountSequence && + page == other.page && + pageSize == other.pageSize; + + @override + int get hashCode => accountSequence.hashCode ^ page.hashCode ^ pageSize.hashCode; +} + +/// 认种记录 Provider +final plantingRecordsProvider = FutureProvider.family( + (ref, params) async { + if (params.accountSequence.isEmpty) { + return null; + } + + final repository = ref.watch(miningRepositoryProvider); + final result = await repository.getPlantingLedger( + params.accountSequence, + page: params.page, + pageSize: params.pageSize, + ); + + ref.keepAlive(); + final timer = Timer(const Duration(minutes: 5), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + + return result.fold( + (failure) => throw Exception(failure.message), + (ledger) => ledger, + ); + }, +);