feat(mining-admin): add mining progress dashboard component

Add real-time mining progress statistics similar to burn progress:
- Backend: new /admin/mining/status endpoint in mining-service
- Frontend: MiningProgress component with progress bar and stats
- Shows: total distributed, remaining pool, minutes left, per-minute rate
- Auto-refresh every 60 seconds via React Query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-17 00:00:20 -08:00
parent ea1e376939
commit e80e672ffe
6 changed files with 256 additions and 2 deletions

View File

@ -176,4 +176,77 @@ export class AdminController {
total: accounts.length,
};
}
@Get('mining/status')
@Public()
@ApiOperation({ summary: '获取挖矿进度状态(类似销毁进度)' })
async getMiningStatus() {
const config = await this.prisma.miningConfig.findFirst();
if (!config) {
return {
totalDistributed: '0',
distributionPool: '0',
remainingDistribution: '0',
miningProgress: '0',
minuteDistribution: '0',
remainingMinutes: 0,
lastMiningMinute: null,
isActive: false,
currentEra: 0,
};
}
// 获取用户已分配总量
const userMiningStats = await this.prisma.miningAccount.aggregate({
_sum: { totalMined: true },
});
// 获取系统账户已分配总量
const systemMiningStats = await this.prisma.systemMiningAccount.aggregate({
_sum: { totalMined: true },
});
// 总分配量 = 用户分配 + 系统账户分配
const userDistributed = userMiningStats._sum.totalMined || 0;
const systemDistributed = systemMiningStats._sum.totalMined || 0;
const totalDistributedDecimal = Number(userDistributed) + Number(systemDistributed);
// 获取最后分配时间(从分钟统计表查询)
const lastMinuteStat = await this.prisma.minuteMiningStat.findFirst({
orderBy: { minute: 'desc' },
select: { minute: true },
});
// 计算每分钟分配量 (秒分配量 * 60)
const secondDistribution = Number(config.secondDistribution || 0);
const minuteDistribution = secondDistribution * 60;
// 计算剩余分配量
const distributionPool = Number(config.distributionPool || 0);
const remainingDistribution = Number(config.remainingDistribution || 0);
// 计算进度百分比
const distributed = distributionPool - remainingDistribution;
const miningProgress = distributionPool > 0
? (distributed / distributionPool) * 100
: 0;
// 计算剩余分钟数
const remainingMinutes = minuteDistribution > 0
? Math.ceil(remainingDistribution / minuteDistribution)
: 0;
return {
totalDistributed: totalDistributedDecimal.toFixed(8),
distributionPool: distributionPool.toFixed(8),
remainingDistribution: remainingDistribution.toFixed(8),
miningProgress: miningProgress.toFixed(4),
minuteDistribution: minuteDistribution.toFixed(8),
remainingMinutes,
lastMiningMinute: lastMinuteStat?.minute || null,
isActive: config.isActive,
currentEra: config.currentEra,
};
}
}

View File

@ -9,23 +9,30 @@ const nextConfig = {
const apiGatewayUrl = process.env.API_GATEWAY_URL || 'https://rwaapi.szaiai.com';
const miningAdminUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023';
const tradingServiceUrl = process.env.TRADING_SERVICE_URL || 'http://localhost:3022';
const miningServiceUrl = process.env.MINING_SERVICE_URL || 'http://localhost:3021';
// 检查是否是生产环境(使用 Kong 网关)
const isProduction = process.env.NODE_ENV === 'production';
// 移除末尾可能存在的路径避免重复
const cleanMiningUrl = miningAdminUrl.replace(/\/api\/v2.*$/, '');
const cleanMiningAdminUrl = miningAdminUrl.replace(/\/api\/v2.*$/, '');
const cleanTradingUrl = tradingServiceUrl.replace(/\/api\/v2.*$/, '');
const cleanMiningUrl = miningServiceUrl.replace(/\/api\/v2.*$/, '');
if (isProduction) {
// 生产环境:通过 Kong 网关
// /api/trading/* -> Kong -> trading-service
// /api/mining/* -> Kong -> mining-service
// /api/* -> Kong -> mining-admin-service
return [
{
source: '/api/trading/:path*',
destination: `${apiGatewayUrl}/api/v2/trading/:path*`,
},
{
source: '/api/mining/:path*',
destination: `${apiGatewayUrl}/api/v2/mining/:path*`,
},
{
source: '/api/:path*',
destination: `${apiGatewayUrl}/api/v2/mining-admin/:path*`,
@ -39,9 +46,13 @@ const nextConfig = {
destination: `${cleanTradingUrl}/api/v2/:path*`,
},
{
source: '/api/:path*',
source: '/api/mining/:path*',
destination: `${cleanMiningUrl}/api/v2/:path*`,
},
{
source: '/api/:path*',
destination: `${cleanMiningAdminUrl}/api/v2/:path*`,
},
];
}
},

View File

@ -5,6 +5,7 @@ import { StatsCards } from '@/features/dashboard/components/stats-cards';
import { RealtimePanel } from '@/features/dashboard/components/realtime-panel';
import { PriceOverview } from '@/features/dashboard/components/price-overview';
import { ContributionBreakdown } from '@/features/dashboard/components/contribution-breakdown';
import { MiningProgress } from '@/features/mining/components/mining-progress';
export default function DashboardPage() {
return (
@ -13,6 +14,11 @@ export default function DashboardPage() {
<StatsCards />
{/* 挖矿进度 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MiningProgress />
</div>
{/* 详细算力分解 */}
<ContributionBreakdown />

View File

@ -0,0 +1,59 @@
import axios from 'axios';
// Mining API 需要独立的 baseURL因为它走不同的路由
// 生产环境: 通过 Next.js rewrite /api/mining/* -> Kong -> mining-service
// 开发环境: 通过 Next.js rewrite /api/mining/* -> mining-service
const miningBaseURL = '/api/mining';
const miningClient = axios.create({
baseURL: miningBaseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
miningClient.interceptors.request.use(
(config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
miningClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token');
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export interface MiningStatus {
totalDistributed: string; // 已分配总量
distributionPool: string; // 分配池总量 (2亿)
remainingDistribution: string; // 剩余分配量
miningProgress: string; // 进度百分比
minuteDistribution: string; // 每分钟分配量
remainingMinutes: number; // 预估剩余分钟数
lastMiningMinute: string | null; // 最后分配时间
isActive: boolean; // 系统是否激活
currentEra: number; // 当前纪元
}
export const miningApi = {
// 获取挖矿进度状态
getMiningStatus: async (): Promise<MiningStatus> => {
const response = await miningClient.get('/admin/mining/status');
// 后端返回 { success, data, timestamp }
return response.data.data || response.data;
},
};

View File

@ -0,0 +1,94 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Pickaxe, CheckCircle2, Pause } from 'lucide-react';
import { useMiningStatus } from '../hooks/use-mining';
export function MiningProgress() {
const { data: miningStatus, isLoading } = useMiningStatus();
const formatNumber = (value: string | undefined, decimals: number = 2) => {
if (!value) return '0';
const num = parseFloat(value);
if (isNaN(num)) return '0';
return num.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Pickaxe className="h-5 w-5 text-green-500" />
</CardTitle>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : miningStatus?.isActive ? (
<Badge variant="default" className="flex items-center gap-1 bg-green-500">
<CheckCircle2 className="h-3 w-3" />
</Badge>
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<Pause className="h-3 w-3" />
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : !miningStatus ? (
<div className="text-center py-4 text-muted-foreground">
<p></p>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-semibold">{formatNumber(miningStatus.miningProgress, 4)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div
className="bg-green-500 h-3 rounded-full transition-all"
style={{ width: `${Math.min(parseFloat(miningStatus.miningProgress), 100)}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold text-green-500">{formatNumber(miningStatus.totalDistributed, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(miningStatus.remainingDistribution, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{miningStatus.remainingMinutes?.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(miningStatus.minuteDistribution, 8)}</p>
</div>
</div>
{miningStatus.lastMiningMinute && (
<p className="text-sm text-muted-foreground pt-2 border-t">
: {new Date(miningStatus.lastMiningMinute).toLocaleString()}
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { miningApi } from '../api/mining.api';
export function useMiningStatus() {
return useQuery({
queryKey: ['mining', 'status'],
queryFn: () => miningApi.getMiningStatus(),
refetchInterval: 60000, // 每60秒刷新
staleTime: 30000, // 30秒内数据视为新鲜
});
}