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;
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,
};
}

View File

@ -9,6 +9,7 @@ class ShareAccountModel extends ShareAccount {
required super.totalMined,
required super.perSecondEarning,
required super.effectiveContribution,
super.firstMiningDate,
});
factory ShareAccountModel.fromJson(Map<String, dynamic> 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(),
};
}
}

View File

@ -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,
];
}

View File

@ -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);
// 2730
// 使
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<Color>(
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),
),
],
],
),
);