feat(mining-app): 贡献值730天失效倒计时功能

将贡献值页面的"贡献值失效倒计时"从硬编码静态文字改为基于用户
首次挖矿时间的真实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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-13 07:30:01 -08:00
parent 6082725c80
commit bc3d800936
4 changed files with 71 additions and 23 deletions

View File

@ -12,6 +12,7 @@ export interface MiningAccountDto {
totalContribution: string; totalContribution: string;
perSecondEarning: string; // 每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量 perSecondEarning: string; // 每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量
lastSyncedAt: Date | null; lastSyncedAt: Date | null;
firstMiningDate: Date | null; // 首次挖矿时间用于730天倒计时
} }
export interface MiningRecordDto { export interface MiningRecordDto {
@ -53,12 +54,20 @@ export class GetMiningAccountQuery {
// 计算每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量 // 计算每秒收益 = (用户贡献 / 全网贡献) × 每秒分配量
// 只有在挖矿系统激活时才返回非零值 // 只有在挖矿系统激活时才返回非零值
let perSecondEarning = '0'; let perSecondEarning = '0';
let firstMiningDate: Date | null = null;
try { try {
const [config, totalContribution] = await Promise.all([ const [config, totalContribution, firstRecord] = await Promise.all([
this.configRepository.getConfig(), this.configRepository.getConfig(),
this.accountRepository.getTotalContribution(), 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) { if (config && config.isActive && totalContribution.value.toNumber() > 0) {
const userContribution = account.totalContribution.value.toNumber(); const userContribution = account.totalContribution.value.toNumber();
@ -79,6 +88,7 @@ export class GetMiningAccountQuery {
totalContribution: account.totalContribution.toString(), totalContribution: account.totalContribution.toString(),
perSecondEarning, perSecondEarning,
lastSyncedAt: account.lastSyncedAt, lastSyncedAt: account.lastSyncedAt,
firstMiningDate,
}; };
} }

View File

@ -9,6 +9,7 @@ class ShareAccountModel extends ShareAccount {
required super.totalMined, required super.totalMined,
required super.perSecondEarning, required super.perSecondEarning,
required super.effectiveContribution, required super.effectiveContribution,
super.firstMiningDate,
}); });
factory ShareAccountModel.fromJson(Map<String, dynamic> json) { factory ShareAccountModel.fromJson(Map<String, dynamic> json) {
@ -23,6 +24,9 @@ class ShareAccountModel extends ShareAccount {
perSecondEarning: json['perSecondEarning']?.toString() ?? '0', perSecondEarning: json['perSecondEarning']?.toString() ?? '0',
// totalContribution effectiveContribution // totalContribution effectiveContribution
effectiveContribution: json['totalContribution']?.toString() ?? json['effectiveContribution']?.toString() ?? '0', 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, 'totalMined': totalMined,
'perSecondEarning': perSecondEarning, 'perSecondEarning': perSecondEarning,
'effectiveContribution': effectiveContribution, 'effectiveContribution': effectiveContribution,
'firstMiningDate': firstMiningDate?.toIso8601String(),
}; };
} }
} }

View File

@ -8,6 +8,7 @@ class ShareAccount extends Equatable {
final String totalMined; final String totalMined;
final String perSecondEarning; final String perSecondEarning;
final String effectiveContribution; final String effectiveContribution;
final DateTime? firstMiningDate;
const ShareAccount({ const ShareAccount({
required this.accountSequence, required this.accountSequence,
@ -17,6 +18,7 @@ class ShareAccount extends Equatable {
required this.totalMined, required this.totalMined,
required this.perSecondEarning, required this.perSecondEarning,
required this.effectiveContribution, required this.effectiveContribution,
this.firstMiningDate,
}); });
String get totalBalance { String get totalBalance {
@ -35,5 +37,6 @@ class ShareAccount extends Equatable {
totalMined, totalMined,
perSecondEarning, perSecondEarning,
effectiveContribution, effectiveContribution,
firstMiningDate,
]; ];
} }

View File

@ -5,8 +5,10 @@ import '../../../core/constants/app_colors.dart';
import '../../../core/router/routes.dart'; import '../../../core/router/routes.dart';
import '../../../core/utils/format_utils.dart'; import '../../../core/utils/format_utils.dart';
import '../../../domain/entities/contribution.dart'; import '../../../domain/entities/contribution.dart';
import '../../../domain/entities/share_account.dart';
import '../../providers/user_providers.dart'; import '../../providers/user_providers.dart';
import '../../providers/contribution_providers.dart'; import '../../providers/contribution_providers.dart';
import '../../providers/mining_providers.dart';
import '../../widgets/shimmer_loading.dart'; import '../../widgets/shimmer_loading.dart';
class ContributionPage extends ConsumerWidget { class ContributionPage extends ConsumerWidget {
@ -25,6 +27,8 @@ class ContributionPage extends ConsumerWidget {
final estimatedEarnings = ref.watch(estimatedEarningsProvider(accountSequence)); final estimatedEarnings = ref.watch(estimatedEarningsProvider(accountSequence));
// //
final sharePoolAsync = ref.watch(sharePoolBalanceProvider); final sharePoolAsync = ref.watch(sharePoolBalanceProvider);
// 730
final shareAccountAsync = ref.watch(shareAccountProvider(accountSequence));
// Extract loading state and data from AsyncValue // Extract loading state and data from AsyncValue
final isLoading = contributionAsync.isLoading; final isLoading = contributionAsync.isLoading;
@ -42,6 +46,7 @@ class ContributionPage extends ConsumerWidget {
onRefresh: () async { onRefresh: () async {
ref.invalidate(contributionProvider(accountSequence)); ref.invalidate(contributionProvider(accountSequence));
ref.invalidate(sharePoolBalanceProvider); ref.invalidate(sharePoolBalanceProvider);
ref.invalidate(shareAccountProvider(accountSequence));
}, },
child: hasError && contribution == null child: hasError && contribution == null
? Center( ? Center(
@ -84,7 +89,7 @@ class ContributionPage extends ConsumerWidget {
_buildTeamStatsCard(context, contribution, isLoading), _buildTeamStatsCard(context, contribution, isLoading),
const SizedBox(height: 16), const SizedBox(height: 16),
// //
_buildExpirationCard(context, contribution, isLoading), _buildExpirationCard(context, contribution, isLoading, shareAccountAsync.valueOrNull),
const SizedBox(height: 24), const SizedBox(height: 24),
]), ]),
), ),
@ -648,19 +653,42 @@ class ContributionPage extends ConsumerWidget {
BuildContext context, BuildContext context,
Contribution? contribution, Contribution? contribution,
bool isLoading, bool isLoading,
ShareAccount? shareAccount,
) { ) {
final isDark = AppColors.isDark(context); final isDark = AppColors.isDark(context);
// 2730
// 使
const int validityDays = 730; const int validityDays = 730;
final hasContribution = contribution != null &&
(double.tryParse(contribution.totalContribution) ?? 0) > 0;
// //
final String expireDateText = hasContribution final DateTime? firstMiningDate = shareAccount?.firstMiningDate;
? '贡献值自生效日起 $validityDays 天内有效'
: '暂无贡献值'; int daysElapsed = 0;
final double progress = hasContribution ? 1.0 : 0.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); final bgGrayColor = isDark ? AppColors.backgroundOf(context) : const Color(0xFFF3F4F6);
return Container( return Container(
@ -684,7 +712,7 @@ class ContributionPage extends ConsumerWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// //
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator( child: LinearProgressIndicator(
@ -692,7 +720,7 @@ class ContributionPage extends ConsumerWidget {
minHeight: 10, minHeight: 10,
backgroundColor: bgGrayColor, backgroundColor: bgGrayColor,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
isLoading ? bgGrayColor : _orange, isLoading ? bgGrayColor : activeColor,
), ),
), ),
), ),
@ -707,16 +735,18 @@ class ContributionPage extends ConsumerWidget {
expireDateText, expireDateText,
style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)), style: TextStyle(fontSize: 12, color: AppColors.textSecondaryOf(context)),
), ),
const SizedBox(height: 4), if (countdownText.isNotEmpty) ...[
isLoading const SizedBox(height: 4),
? const ShimmerText( isLoading
placeholder: '有效期 --- 天', ? const ShimmerText(
style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), placeholder: '剩余 --- 天',
) style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
: const Text( )
'有效期 $validityDays', : Text(
style: TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500), countdownText,
), style: TextStyle(fontSize: 12, color: activeColor, fontWeight: FontWeight.w500),
),
],
], ],
), ),
); );