feat(pending-contributions): 添加待解锁算力分类账功能

功能说明:
- 待解锁算力是因用户未满足解锁条件(如直推数不足)而暂存的层级/奖励算力
- 这部分算力参与挖矿,但收益归入总部账户(HEADQUARTERS)

后端变更:
- mining-service: 添加4个待解锁算力相关API
  - GET /admin/pending-contributions - 获取待解锁算力列表(支持分页和类型筛选)
  - GET /admin/pending-contributions/summary - 获取汇总统计(按类型统计、总挖矿收益)
  - GET /admin/pending-contributions/:id/records - 获取单条记录的挖矿明细
  - GET /admin/pending-contributions/mining-records - 获取所有挖矿记录汇总视图
- mining-admin-service: 添加代理层
  - 新建 PendingContributionsService 调用 mining-service API
  - 新建 PendingContributionsController 暴露 API 给前端

前端变更:
- 新建 pending-contributions feature 模块(API、hooks、类型定义)
- 新建 /pending-contributions 页面
  - 汇总统计卡片(总量、已归入总部积分股、挖矿记录数)
  - 按类型统计展示
  - 算力列表Tab(来源用户、归属用户、类型、算力、原因、创建时间)
  - 挖矿记录Tab(时间、类型、算力、占比、每秒分配量、挖得数量、归入)
- 在仪表盘的层级算力和团队奖励卡片添加"查看分类账"链接

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 18:13:22 -08:00
parent d815792deb
commit 63c192e90d
10 changed files with 1136 additions and 9 deletions

View File

@ -9,6 +9,7 @@ import { UsersController } from './controllers/users.controller';
import { SystemAccountsController } from './controllers/system-accounts.controller';
import { ReportsController } from './controllers/reports.controller';
import { ManualMiningController } from './controllers/manual-mining.controller';
import { PendingContributionsController } from './controllers/pending-contributions.controller';
@Module({
imports: [ApplicationModule],
@ -22,6 +23,7 @@ import { ManualMiningController } from './controllers/manual-mining.controller';
SystemAccountsController,
ReportsController,
ManualMiningController,
PendingContributionsController,
],
})
export class ApiModule {}

View File

@ -0,0 +1,77 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { PendingContributionsService } from '../../application/services/pending-contributions.service';
@ApiTags('Pending Contributions')
@ApiBearerAuth()
@Controller('pending-contributions')
export class PendingContributionsController {
constructor(
private readonly pendingContributionsService: PendingContributionsService,
) {}
@Get()
@ApiOperation({ summary: '获取待解锁算力列表' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiQuery({
name: 'contributionType',
required: false,
type: String,
description: '算力类型筛选',
})
async getPendingContributions(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
@Query('contributionType') contributionType?: string,
) {
return this.pendingContributionsService.getPendingContributions(
page ?? 1,
pageSize ?? 20,
contributionType,
);
}
@Get('summary')
@ApiOperation({ summary: '获取待解锁算力汇总统计' })
async getPendingContributionsSummary() {
return this.pendingContributionsService.getPendingContributionsSummary();
}
@Get('mining-records')
@ApiOperation({ summary: '获取所有待解锁算力的挖矿记录' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getAllPendingMiningRecords(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.pendingContributionsService.getAllPendingMiningRecords(
page ?? 1,
pageSize ?? 20,
);
}
@Get(':id/records')
@ApiOperation({ summary: '获取某条待解锁算力的挖矿记录' })
@ApiParam({ name: 'id', type: String, description: '待解锁算力ID' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getPendingContributionMiningRecords(
@Param('id') id: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
return this.pendingContributionsService.getPendingContributionMiningRecords(
id,
page ?? 1,
pageSize ?? 20,
);
}
}

View File

@ -7,6 +7,7 @@ import { UsersService } from './services/users.service';
import { SystemAccountsService } from './services/system-accounts.service';
import { DailyReportService } from './services/daily-report.service';
import { ManualMiningService } from './services/manual-mining.service';
import { PendingContributionsService } from './services/pending-contributions.service';
@Module({
imports: [InfrastructureModule],
@ -18,6 +19,7 @@ import { ManualMiningService } from './services/manual-mining.service';
SystemAccountsService,
DailyReportService,
ManualMiningService,
PendingContributionsService,
],
exports: [
AuthService,
@ -27,6 +29,7 @@ import { ManualMiningService } from './services/manual-mining.service';
SystemAccountsService,
DailyReportService,
ManualMiningService,
PendingContributionsService,
],
})
export class ApplicationModule implements OnModuleInit {

View File

@ -0,0 +1,138 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
@Injectable()
export class PendingContributionsService {
private readonly logger = new Logger(PendingContributionsService.name);
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
private getMiningServiceUrl(): string {
return this.configService.get<string>(
'MINING_SERVICE_URL',
'http://localhost:3021',
);
}
/**
*
*/
async getPendingContributions(
page: number = 1,
pageSize: number = 20,
contributionType?: string,
) {
const miningServiceUrl = this.getMiningServiceUrl();
try {
const params: any = { page, pageSize };
if (contributionType) {
params.contributionType = contributionType;
}
const response = await firstValueFrom(
this.httpService.get(`${miningServiceUrl}/admin/pending-contributions`, {
params,
}),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch pending contributions: ${error.message}`,
);
return { contributions: [], total: 0, page, pageSize };
}
}
/**
*
*/
async getPendingContributionsSummary() {
const miningServiceUrl = this.getMiningServiceUrl();
try {
const response = await firstValueFrom(
this.httpService.get(
`${miningServiceUrl}/admin/pending-contributions/summary`,
),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch pending contributions summary: ${error.message}`,
);
return {
byType: [],
total: { totalAmount: '0', count: 0 },
totalMinedToHeadquarters: '0',
};
}
}
/**
*
*/
async getPendingContributionMiningRecords(
id: string,
page: number = 1,
pageSize: number = 20,
) {
const miningServiceUrl = this.getMiningServiceUrl();
try {
const response = await firstValueFrom(
this.httpService.get(
`${miningServiceUrl}/admin/pending-contributions/${id}/records`,
{
params: { page, pageSize },
},
),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch pending contribution mining records: ${error.message}`,
);
return {
pendingContribution: null,
records: [],
total: 0,
page,
pageSize,
};
}
}
/**
*
*/
async getAllPendingMiningRecords(page: number = 1, pageSize: number = 20) {
const miningServiceUrl = this.getMiningServiceUrl();
try {
const response = await firstValueFrom(
this.httpService.get(
`${miningServiceUrl}/admin/pending-contributions/mining-records`,
{
params: { page, pageSize },
},
),
);
return response.data;
} catch (error) {
this.logger.warn(
`Failed to fetch all pending mining records: ${error.message}`,
);
return { records: [], total: 0, page, pageSize };
}
}
}

View File

@ -424,4 +424,213 @@ export class AdminController {
await this.manualMiningService.markWalletSynced(recordId);
return { success: true, message: '已标记为同步完成' };
}
// ==================== 待解锁算力Pending Contributions====================
@Get('pending-contributions')
@Public()
@ApiOperation({ summary: '获取待解锁算力列表' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
@ApiQuery({ name: 'contributionType', required: false, type: String, description: '算力类型筛选' })
async getPendingContributions(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
@Query('contributionType') contributionType?: string,
) {
const pageNum = page ?? 1;
const pageSizeNum = pageSize ?? 20;
const skip = (pageNum - 1) * pageSizeNum;
const where: any = { isExpired: false };
if (contributionType) {
where.contributionType = contributionType;
}
const [contributions, total] = await Promise.all([
this.prisma.pendingContributionMining.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSizeNum,
}),
this.prisma.pendingContributionMining.count({ where }),
]);
return {
contributions: contributions.map((c) => ({
id: c.id.toString(),
sourceAdoptionId: c.sourceAdoptionId.toString(),
sourceAccountSequence: c.sourceAccountSequence,
wouldBeAccountSequence: c.wouldBeAccountSequence,
contributionType: c.contributionType,
amount: c.amount.toString(),
reason: c.reason,
effectiveDate: c.effectiveDate,
expireDate: c.expireDate,
isExpired: c.isExpired,
lastSyncedAt: c.lastSyncedAt,
createdAt: c.createdAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
};
}
@Get('pending-contributions/summary')
@Public()
@ApiOperation({ summary: '获取待解锁算力汇总统计' })
async getPendingContributionsSummary() {
// 按类型分组统计
const byType = await this.prisma.pendingContributionMining.groupBy({
by: ['contributionType'],
where: { isExpired: false },
_sum: { amount: true },
_count: { id: true },
});
// 总计
const total = await this.prisma.pendingContributionMining.aggregate({
where: { isExpired: false },
_sum: { amount: true },
_count: { id: true },
});
// 总挖矿收益(归总部)
const totalMined = await this.prisma.pendingMiningRecord.aggregate({
_sum: { minedAmount: true },
});
return {
byType: byType.map((t) => ({
contributionType: t.contributionType,
totalAmount: t._sum.amount?.toString() || '0',
count: t._count.id,
})),
total: {
totalAmount: total._sum.amount?.toString() || '0',
count: total._count.id,
},
totalMinedToHeadquarters: totalMined._sum.minedAmount?.toString() || '0',
};
}
@Get('pending-contributions/:id/records')
@Public()
@ApiOperation({ summary: '获取某条待解锁算力的挖矿记录' })
@ApiParam({ name: 'id', type: String, description: '待解锁算力ID' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getPendingContributionMiningRecords(
@Param('id') id: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const pageNum = page ?? 1;
const pageSizeNum = pageSize ?? 20;
const skip = (pageNum - 1) * pageSizeNum;
const pendingId = BigInt(id);
// 获取待解锁算力信息
const pending = await this.prisma.pendingContributionMining.findUnique({
where: { id: pendingId },
});
if (!pending) {
throw new HttpException('待解锁算力记录不存在', HttpStatus.NOT_FOUND);
}
const [records, total] = await Promise.all([
this.prisma.pendingMiningRecord.findMany({
where: { pendingContributionId: pendingId },
orderBy: { miningMinute: 'desc' },
skip,
take: pageSizeNum,
}),
this.prisma.pendingMiningRecord.count({
where: { pendingContributionId: pendingId },
}),
]);
return {
pendingContribution: {
id: pending.id.toString(),
sourceAdoptionId: pending.sourceAdoptionId.toString(),
sourceAccountSequence: pending.sourceAccountSequence,
wouldBeAccountSequence: pending.wouldBeAccountSequence,
contributionType: pending.contributionType,
amount: pending.amount.toString(),
reason: pending.reason,
},
records: records.map((r) => ({
id: r.id.toString(),
miningMinute: r.miningMinute,
contributionAmount: r.contributionAmount.toString(),
networkTotalContribution: r.networkTotalContribution.toString(),
contributionRatio: r.contributionRatio.toString(),
secondDistribution: r.secondDistribution.toString(),
minedAmount: r.minedAmount.toString(),
allocatedTo: r.allocatedTo,
createdAt: r.createdAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
};
}
@Get('pending-contributions/mining-records')
@Public()
@ApiOperation({ summary: '获取所有待解锁算力的挖矿记录(汇总视图)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'pageSize', required: false, type: Number })
async getAllPendingMiningRecords(
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
) {
const pageNum = page ?? 1;
const pageSizeNum = pageSize ?? 20;
const skip = (pageNum - 1) * pageSizeNum;
const [records, total] = await Promise.all([
this.prisma.pendingMiningRecord.findMany({
orderBy: { miningMinute: 'desc' },
skip,
take: pageSizeNum,
include: {
pendingContribution: {
select: {
contributionType: true,
wouldBeAccountSequence: true,
reason: true,
},
},
},
}),
this.prisma.pendingMiningRecord.count(),
]);
return {
records: records.map((r) => ({
id: r.id.toString(),
pendingContributionId: r.pendingContributionId.toString(),
miningMinute: r.miningMinute,
sourceAccountSequence: r.sourceAccountSequence,
wouldBeAccountSequence: r.wouldBeAccountSequence,
contributionType: r.contributionType,
contributionAmount: r.contributionAmount.toString(),
networkTotalContribution: r.networkTotalContribution.toString(),
contributionRatio: r.contributionRatio.toString(),
secondDistribution: r.secondDistribution.toString(),
minedAmount: r.minedAmount.toString(),
allocatedTo: r.allocatedTo,
reason: r.pendingContribution?.reason,
createdAt: r.createdAt,
})),
total,
page: pageNum,
pageSize: pageSizeNum,
};
}
}

View File

@ -0,0 +1,506 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertCircle,
RefreshCw,
Pickaxe,
List,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import {
usePendingContributions,
usePendingContributionsSummary,
useAllPendingMiningRecords,
} from '@/features/pending-contributions';
import { formatDecimal } from '@/lib/utils/format';
import { PageHeader } from '@/components/layout/page-header';
const CONTRIBUTION_TYPE_LABELS: Record<string, { label: string; color: string }> = {
LEVEL_1: { label: '一级', color: 'bg-blue-100 text-blue-800' },
LEVEL_2: { label: '二级', color: 'bg-green-100 text-green-800' },
LEVEL_3: { label: '三级', color: 'bg-purple-100 text-purple-800' },
BONUS_1: { label: '奖励一级', color: 'bg-orange-100 text-orange-800' },
BONUS_2: { label: '奖励二级', color: 'bg-yellow-100 text-yellow-800' },
BONUS_3: { label: '奖励三级', color: 'bg-red-100 text-red-800' },
};
export default function PendingContributionsPage() {
const queryClient = useQueryClient();
const [listPage, setListPage] = useState(1);
const [miningPage, setMiningPage] = useState(1);
const [typeFilter, setTypeFilter] = useState<string>('all');
const pageSize = 20;
// 获取汇总统计
const {
data: summary,
isLoading: summaryLoading,
error: summaryError,
} = usePendingContributionsSummary();
// 获取待解锁算力列表
const {
data: contributions,
isLoading: listLoading,
error: listError,
} = usePendingContributions(
listPage,
pageSize,
typeFilter !== 'all' ? typeFilter : undefined
);
// 获取所有挖矿记录
const {
data: miningRecords,
isLoading: miningLoading,
error: miningError,
} = useAllPendingMiningRecords(miningPage, pageSize);
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['pending-contributions'] });
};
const listTotalPages = contributions
? Math.ceil(contributions.total / pageSize)
: 0;
const miningTotalPages = miningRecords
? Math.ceil(miningRecords.total / pageSize)
: 0;
return (
<div className="space-y-6">
<PageHeader
title="待解锁算力分类账"
description="查看未满足解锁条件的算力及其挖矿收益(归入总部账户)"
actions={
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={listLoading || miningLoading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${listLoading || miningLoading ? 'animate-spin' : ''}`}
/>
</Button>
}
/>
{/* 汇总统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{summaryLoading ? (
<Skeleton className="h-8 w-32" />
) : summaryError ? (
<span className="text-red-500 text-sm"></span>
) : (
<>
<div className="text-2xl font-bold text-yellow-600">
{formatDecimal(summary?.total?.totalAmount || '0', 2)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{summary?.total?.count || 0}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{summaryLoading ? (
<Skeleton className="h-8 w-32" />
) : summaryError ? (
<span className="text-red-500 text-sm"></span>
) : (
<div className="text-2xl font-bold text-green-600">
{formatDecimal(summary?.totalMinedToHeadquarters || '0', 8)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
{miningLoading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">{miningRecords?.total || 0}</div>
)}
</CardContent>
</Card>
</div>
{/* 按类型统计 */}
{summary?.byType && summary.byType.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{summary.byType.map((item) => {
const typeInfo = CONTRIBUTION_TYPE_LABELS[item.contributionType] || {
label: item.contributionType,
color: 'bg-gray-100 text-gray-800',
};
return (
<div
key={item.contributionType}
className="p-3 rounded-lg border bg-card"
>
<Badge className={`${typeInfo.color} mb-2`}>
{typeInfo.label}
</Badge>
<div className="space-y-1">
<div className="text-sm text-muted-foreground">
: <span className="font-mono text-yellow-600">{formatDecimal(item.totalAmount, 2)}</span>
</div>
<div className="text-sm text-muted-foreground">
: <span className="font-mono">{item.count}</span>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* 分类账 Tabs */}
<Tabs defaultValue="list" className="space-y-4">
<TabsList>
<TabsTrigger value="list" className="flex items-center gap-2">
<List className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="mining" className="flex items-center gap-2">
<Pickaxe className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 算力列表 Tab */}
<TabsContent value="list">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{contributions && (
<Badge variant="secondary" className="text-xs">
{contributions.total}
</Badge>
)}
</CardTitle>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(CONTRIBUTION_TYPE_LABELS).map(([key, info]) => (
<SelectItem key={key} value={key}>
{info.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
{listError ? (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(6)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !contributions?.contributions.length ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground py-8"
>
</TableCell>
</TableRow>
) : (
contributions.contributions.map((item) => {
const typeInfo = CONTRIBUTION_TYPE_LABELS[item.contributionType] || {
label: item.contributionType,
color: 'bg-gray-100 text-gray-800',
};
return (
<TableRow key={item.id}>
<TableCell>
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{item.sourceAccountSequence}
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{item.wouldBeAccountSequence}
</code>
</TableCell>
<TableCell>
<Badge className={`${typeInfo.color} text-xs`}>
{typeInfo.label}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-yellow-600">
{formatDecimal(item.amount, 2)}
</TableCell>
<TableCell className="text-muted-foreground text-sm max-w-[200px] truncate">
{item.reason || '-'}
</TableCell>
<TableCell>
{format(
new Date(item.createdAt),
'yyyy-MM-dd HH:mm',
{ locale: zhCN }
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* 分页 */}
{listTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setListPage((p) => Math.max(1, p - 1))}
disabled={listPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{listPage} / {listTotalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setListPage((p) => Math.min(listTotalPages, p + 1))
}
disabled={listPage >= listTotalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 挖矿记录 Tab */}
<TabsContent value="mining">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{miningRecords && (
<Badge variant="secondary" className="text-xs">
{miningRecords.total}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{miningError ? (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{miningLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
{[...Array(7)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !miningRecords?.records.length ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-8"
>
</TableCell>
</TableRow>
) : (
miningRecords.records.map((record) => {
const typeInfo = CONTRIBUTION_TYPE_LABELS[record.contributionType || ''] || {
label: record.contributionType || '-',
color: 'bg-gray-100 text-gray-800',
};
return (
<TableRow key={record.id}>
<TableCell>
{format(
new Date(record.miningMinute),
'yyyy-MM-dd HH:mm',
{ locale: zhCN }
)}
</TableCell>
<TableCell>
<Badge className={`${typeInfo.color} text-xs`}>
{typeInfo.label}
</Badge>
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(record.contributionAmount, 2)}
</TableCell>
<TableCell className="text-right font-mono">
{(Number(record.contributionRatio) * 100).toFixed(6)}%
</TableCell>
<TableCell className="text-right font-mono">
{formatDecimal(record.secondDistribution, 8)}
</TableCell>
<TableCell className="text-right font-mono text-green-600">
+{formatDecimal(record.minedAmount, 8)}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{record.allocatedTo === 'HEADQUARTERS' ? '总部' : record.allocatedTo}
</Badge>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* 分页 */}
{miningTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setMiningPage((p) => Math.max(1, p - 1))}
disabled={miningPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground px-4">
{miningPage} / {miningTotalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setMiningPage((p) => Math.min(miningTotalPages, p + 1))
}
disabled={miningPage >= miningTotalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,10 +1,12 @@
'use client';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useDashboardStats } from '../hooks/use-dashboard-stats';
import { formatCompactNumber } from '@/lib/utils/format';
import { Skeleton } from '@/components/ui/skeleton';
import { Activity, Users, Building2, Landmark, Layers, Gift, TreePine } from 'lucide-react';
import { Activity, Users, Building2, Landmark, Layers, Gift, TreePine, ExternalLink } from 'lucide-react';
function ContributionBreakdownSkeleton() {
return (
@ -132,10 +134,18 @@ export function ContributionBreakdown() {
{/* 层级算力详情 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-600" />
(7.5%)
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-600" />
(7.5%)
</CardTitle>
<Link href="/pending-contributions">
<Button variant="ghost" size="sm" className="text-xs h-7">
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
<p className="text-xs text-muted-foreground">
: {formatCompactNumber(dc.level.theory)} |
<span className="text-green-600"> : {formatCompactNumber(dc.level.unlocked)}</span> |
@ -173,10 +183,18 @@ export function ContributionBreakdown() {
{/* 团队奖励详情 */}
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Gift className="h-5 w-5 text-purple-600" />
(7.5%)
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Gift className="h-5 w-5 text-purple-600" />
(7.5%)
</CardTitle>
<Link href="/pending-contributions">
<Button variant="ghost" size="sm" className="text-xs h-7">
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
<p className="text-xs text-muted-foreground">
: {formatCompactNumber(dc.bonus.theory)} |
<span className="text-green-600"> : {formatCompactNumber(dc.bonus.unlocked)}</span> |

View File

@ -0,0 +1,114 @@
import { apiClient } from '@/lib/api/client';
// 待解锁算力记录(来自 mining-service 的 PendingContributionMining 表)
export interface PendingContribution {
id: string;
sourceAdoptionId: string;
sourceAccountSequence: string;
wouldBeAccountSequence: string;
contributionType: string;
amount: string;
reason: string;
effectiveDate: string;
expireDate: string;
isExpired: boolean;
lastSyncedAt: string | null;
createdAt: string;
}
// 待解锁算力列表响应
export interface PendingContributionsResponse {
contributions: PendingContribution[];
total: number;
page: number;
pageSize: number;
}
// 待解锁算力汇总统计
export interface PendingContributionsSummary {
byType: Array<{
contributionType: string;
totalAmount: string;
count: number;
}>;
total: {
totalAmount: string;
count: number;
};
totalMinedToHeadquarters: string;
}
// 待解锁算力的挖矿记录
export interface PendingMiningRecord {
id: string;
pendingContributionId?: string;
miningMinute: string;
sourceAccountSequence?: string;
wouldBeAccountSequence?: string;
contributionType?: string;
contributionAmount: string;
networkTotalContribution: string;
contributionRatio: string;
secondDistribution: string;
minedAmount: string;
allocatedTo: string;
reason?: string;
createdAt: string;
}
// 挖矿记录响应
export interface PendingMiningRecordsResponse {
records: PendingMiningRecord[];
total: number;
page: number;
pageSize: number;
}
export const pendingContributionsApi = {
// 获取待解锁算力列表
getList: async (
page: number = 1,
pageSize: number = 20,
contributionType?: string
): Promise<PendingContributionsResponse> => {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('pageSize', pageSize.toString());
if (contributionType) {
params.append('contributionType', contributionType);
}
const response = await apiClient.get(
`/pending-contributions?${params.toString()}`
);
return response.data.data;
},
// 获取汇总统计
getSummary: async (): Promise<PendingContributionsSummary> => {
const response = await apiClient.get('/pending-contributions/summary');
return response.data.data;
},
// 获取所有待解锁算力的挖矿记录
getAllMiningRecords: async (
page: number = 1,
pageSize: number = 20
): Promise<PendingMiningRecordsResponse> => {
const response = await apiClient.get(
`/pending-contributions/mining-records?page=${page}&pageSize=${pageSize}`
);
return response.data.data;
},
// 获取某条待解锁算力的挖矿记录
getMiningRecords: async (
id: string,
page: number = 1,
pageSize: number = 20
): Promise<PendingMiningRecordsResponse> => {
const response = await apiClient.get(
`/pending-contributions/${id}/records?page=${page}&pageSize=${pageSize}`
);
return response.data.data;
},
};

View File

@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { pendingContributionsApi } from '../api/pending-contributions.api';
export function usePendingContributions(
page: number = 1,
pageSize: number = 20,
contributionType?: string
) {
return useQuery({
queryKey: ['pending-contributions', 'list', page, pageSize, contributionType],
queryFn: () => pendingContributionsApi.getList(page, pageSize, contributionType),
staleTime: 30000,
});
}
export function usePendingContributionsSummary() {
return useQuery({
queryKey: ['pending-contributions', 'summary'],
queryFn: () => pendingContributionsApi.getSummary(),
staleTime: 30000,
});
}
export function useAllPendingMiningRecords(page: number = 1, pageSize: number = 20) {
return useQuery({
queryKey: ['pending-contributions', 'mining-records', 'all', page, pageSize],
queryFn: () => pendingContributionsApi.getAllMiningRecords(page, pageSize),
staleTime: 30000,
});
}
export function usePendingContributionMiningRecords(
id: string,
page: number = 1,
pageSize: number = 20
) {
return useQuery({
queryKey: ['pending-contributions', 'mining-records', id, page, pageSize],
queryFn: () => pendingContributionsApi.getMiningRecords(id, page, pageSize),
enabled: !!id,
staleTime: 30000,
});
}

View File

@ -0,0 +1,17 @@
// API
export {
pendingContributionsApi,
type PendingContribution,
type PendingContributionsResponse,
type PendingContributionsSummary,
type PendingMiningRecord,
type PendingMiningRecordsResponse,
} from './api/pending-contributions.api';
// Hooks
export {
usePendingContributions,
usePendingContributionsSummary,
useAllPendingMiningRecords,
usePendingContributionMiningRecords,
} from './hooks/use-pending-contributions';