From bc3d800936be56f01982f1e026c5f53fe6fc2ae8 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 13 Feb 2026 07:30:01 -0800 Subject: [PATCH] =?UTF-8?q?feat(mining-app):=20=E8=B4=A1=E7=8C=AE=E5=80=BC?= =?UTF-8?q?730=E5=A4=A9=E5=A4=B1=E6=95=88=E5=80=92=E8=AE=A1=E6=97=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将贡献值页面的"贡献值失效倒计时"从硬编码静态文字改为基于用户 首次挖矿时间的真实730天倒计时。纯新增方式实现,不影响现有功能。 后端 (mining-service): - get-mining-account.query.ts: MiningAccountDto 新增 firstMiningDate 字段,在 Promise.all 中并行查询用户最早的 miningRecord,利用 @@unique([accountSequence, miningMinute]) 索引高效查询 前端实体/模型: - share_account.dart: 新增 DateTime? firstMiningDate(可空,向后兼容) - share_account_model.dart: fromJson/toJson 解析和序列化 firstMiningDate 前端 UI (contribution_page.dart): - watch shareAccountProvider 获取首次挖矿时间 - 计算已过天数和剩余天数(730 - 已过天数) - 进度条显示实际已用时间占比 - 显示具体失效日期和剩余天数 - 无挖矿记录 → 显示"暂无挖矿记录" - 已过期 → 显示"贡献值已失效" - 剩余 ≤30 天 → 进度条和文字变红色警告 Co-Authored-By: Claude Opus 4.6 --- .../queries/get-mining-account.query.ts | 12 ++- .../lib/data/models/share_account_model.dart | 5 ++ .../lib/domain/entities/share_account.dart | 3 + .../pages/contribution/contribution_page.dart | 74 +++++++++++++------ 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/backend/services/mining-service/src/application/queries/get-mining-account.query.ts b/backend/services/mining-service/src/application/queries/get-mining-account.query.ts index 6083204e..fc5b1098 100644 --- a/backend/services/mining-service/src/application/queries/get-mining-account.query.ts +++ b/backend/services/mining-service/src/application/queries/get-mining-account.query.ts @@ -12,6 +12,7 @@ export interface MiningAccountDto { totalContribution: string; perSecondEarning: string; // 每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量 lastSyncedAt: Date | null; + firstMiningDate: Date | null; // 首次挖矿时间(用于730天倒计时) } export interface MiningRecordDto { @@ -53,12 +54,20 @@ export class GetMiningAccountQuery { // 计算每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量 // 只有在挖矿系统激活时才返回非零值 let perSecondEarning = '0'; + let firstMiningDate: Date | null = null; try { - const [config, totalContribution] = await Promise.all([ + const [config, totalContribution, firstRecord] = await Promise.all([ this.configRepository.getConfig(), this.accountRepository.getTotalContribution(), + this.prisma.miningRecord.findFirst({ + where: { accountSequence }, + orderBy: { miningMinute: 'asc' }, + select: { miningMinute: true }, + }), ]); + firstMiningDate = firstRecord?.miningMinute ?? null; + // 检查挖矿系统是否激活 if (config && config.isActive && totalContribution.value.toNumber() > 0) { const userContribution = account.totalContribution.value.toNumber(); @@ -79,6 +88,7 @@ export class GetMiningAccountQuery { totalContribution: account.totalContribution.toString(), perSecondEarning, lastSyncedAt: account.lastSyncedAt, + firstMiningDate, }; } diff --git a/frontend/mining-app/lib/data/models/share_account_model.dart b/frontend/mining-app/lib/data/models/share_account_model.dart index 36313072..69ea6be6 100644 --- a/frontend/mining-app/lib/data/models/share_account_model.dart +++ b/frontend/mining-app/lib/data/models/share_account_model.dart @@ -9,6 +9,7 @@ class ShareAccountModel extends ShareAccount { required super.totalMined, required super.perSecondEarning, required super.effectiveContribution, + super.firstMiningDate, }); factory ShareAccountModel.fromJson(Map json) { @@ -23,6 +24,9 @@ class ShareAccountModel extends ShareAccount { perSecondEarning: json['perSecondEarning']?.toString() ?? '0', // 后端返回 totalContribution,映射到 effectiveContribution effectiveContribution: json['totalContribution']?.toString() ?? json['effectiveContribution']?.toString() ?? '0', + firstMiningDate: json['firstMiningDate'] != null + ? DateTime.tryParse(json['firstMiningDate'].toString()) + : null, ); } @@ -35,6 +39,7 @@ class ShareAccountModel extends ShareAccount { 'totalMined': totalMined, 'perSecondEarning': perSecondEarning, 'effectiveContribution': effectiveContribution, + 'firstMiningDate': firstMiningDate?.toIso8601String(), }; } } diff --git a/frontend/mining-app/lib/domain/entities/share_account.dart b/frontend/mining-app/lib/domain/entities/share_account.dart index 665d696c..9bd2d11b 100644 --- a/frontend/mining-app/lib/domain/entities/share_account.dart +++ b/frontend/mining-app/lib/domain/entities/share_account.dart @@ -8,6 +8,7 @@ class ShareAccount extends Equatable { final String totalMined; final String perSecondEarning; final String effectiveContribution; + final DateTime? firstMiningDate; const ShareAccount({ required this.accountSequence, @@ -17,6 +18,7 @@ class ShareAccount extends Equatable { required this.totalMined, required this.perSecondEarning, required this.effectiveContribution, + this.firstMiningDate, }); String get totalBalance { @@ -35,5 +37,6 @@ class ShareAccount extends Equatable { totalMined, perSecondEarning, effectiveContribution, + firstMiningDate, ]; } 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 416955f4..2ca4ff7a 100644 --- a/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart +++ b/frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart @@ -5,8 +5,10 @@ import '../../../core/constants/app_colors.dart'; import '../../../core/router/routes.dart'; import '../../../core/utils/format_utils.dart'; import '../../../domain/entities/contribution.dart'; +import '../../../domain/entities/share_account.dart'; import '../../providers/user_providers.dart'; import '../../providers/contribution_providers.dart'; +import '../../providers/mining_providers.dart'; import '../../widgets/shimmer_loading.dart'; class ContributionPage extends ConsumerWidget { @@ -25,6 +27,8 @@ class ContributionPage extends ConsumerWidget { final estimatedEarnings = ref.watch(estimatedEarningsProvider(accountSequence)); // 获取积分股池余量 final sharePoolAsync = ref.watch(sharePoolBalanceProvider); + // 获取挖矿账户信息(含首次挖矿时间,用于730天倒计时) + final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence)); // Extract loading state and data from AsyncValue final isLoading = contributionAsync.isLoading; @@ -42,6 +46,7 @@ class ContributionPage extends ConsumerWidget { onRefresh: () async { ref.invalidate(contributionProvider(accountSequence)); ref.invalidate(sharePoolBalanceProvider); + ref.invalidate(shareAccountProvider(accountSequence)); }, child: hasError && contribution == null ? Center( @@ -84,7 +89,7 @@ class ContributionPage extends ConsumerWidget { _buildTeamStatsCard(context, contribution, isLoading), const SizedBox(height: 16), // 贡献值失效倒计时 - _buildExpirationCard(context, contribution, isLoading), + _buildExpirationCard(context, contribution, isLoading, shareAccountAsync.valueOrNull), const SizedBox(height: 24), ]), ), @@ -648,19 +653,42 @@ class ContributionPage extends ConsumerWidget { BuildContext context, Contribution? contribution, bool isLoading, + ShareAccount? shareAccount, ) { final isDark = AppColors.isDark(context); - // 贡献值有效期为2年(730天) - // 暂时使用固定信息,后续可从后端获取最近过期日期 const int validityDays = 730; - final hasContribution = contribution != null && - (double.tryParse(contribution.totalContribution) ?? 0) > 0; - // 如果有贡献值,显示有效期提示 - final String expireDateText = hasContribution - ? '贡献值自生效日起 $validityDays 天内有效' - : '暂无贡献值'; - final double progress = hasContribution ? 1.0 : 0.0; + // 从首次挖矿时间计算真实倒计时 + final DateTime? firstMiningDate = shareAccount?.firstMiningDate; + + int daysElapsed = 0; + int daysRemaining = validityDays; + double progress = 0.0; + String expireDateText; + String countdownText; + + if (firstMiningDate == null) { + expireDateText = '暂无挖矿记录'; + countdownText = ''; + } else { + daysElapsed = DateTime.now().difference(firstMiningDate).inDays; + daysRemaining = (validityDays - daysElapsed).clamp(0, validityDays); + progress = (daysElapsed / validityDays).clamp(0.0, 1.0); + + if (daysRemaining <= 0) { + expireDateText = '贡献值已失效'; + countdownText = '已超过 $validityDays 天有效期'; + } else { + final expirationDate = firstMiningDate.add(const Duration(days: validityDays)); + final expirationStr = + '${expirationDate.year}-${expirationDate.month.toString().padLeft(2, '0')}-${expirationDate.day.toString().padLeft(2, '0')}'; + expireDateText = '贡献值将于 $expirationStr 失效'; + countdownText = '剩余 $daysRemaining 天'; + } + } + + final bool isUrgent = firstMiningDate != null && daysRemaining <= 30 && daysRemaining > 0; + final Color activeColor = isUrgent ? Colors.red : _orange; final bgGrayColor = isDark ? AppColors.backgroundOf(context) : const Color(0xFFF3F4F6); return Container( @@ -684,7 +712,7 @@ class ContributionPage extends ConsumerWidget { ], ), const SizedBox(height: 12), - // 进度条 + // 进度条(已用时间占比) ClipRRect( borderRadius: BorderRadius.circular(5), child: LinearProgressIndicator( @@ -692,7 +720,7 @@ class ContributionPage extends ConsumerWidget { minHeight: 10, backgroundColor: bgGrayColor, valueColor: AlwaysStoppedAnimation( - isLoading ? bgGrayColor : _orange, + isLoading ? bgGrayColor : activeColor, ), ), ), @@ -707,16 +735,18 @@ class ContributionPage extends ConsumerWidget { expireDateText, style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)), ), - const SizedBox(height: 4), - isLoading - ? const ShimmerText( - placeholder: '有效期 --- 天', - style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), - ) - : const Text( - '有效期 $validityDays 天', - style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), - ), + if (countdownText.isNotEmpty) ...[ + const SizedBox(height: 4), + isLoading + ? const ShimmerText( + placeholder: '剩余 --- 天', + style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), + ) + : Text( + countdownText, + style: TextStyle(fontSize: 12, color: activeColor, fontWeight: FontWeight.w500), + ), + ], ], ), );