diff --git a/backend/services/mining-service/src/api/controllers/admin.controller.ts b/backend/services/mining-service/src/api/controllers/admin.controller.ts index 4cfe80b2..a3165c1c 100644 --- a/backend/services/mining-service/src/api/controllers/admin.controller.ts +++ b/backend/services/mining-service/src/api/controllers/admin.controller.ts @@ -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, + }; + } } diff --git a/frontend/mining-admin-web/next.config.js b/frontend/mining-admin-web/next.config.js index 2a60a2fa..ea43d815 100644 --- a/frontend/mining-admin-web/next.config.js +++ b/frontend/mining-admin-web/next.config.js @@ -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*`, + }, ]; } }, diff --git a/frontend/mining-admin-web/src/app/(dashboard)/dashboard/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/dashboard/page.tsx index 379cf841..5e336e9e 100644 --- a/frontend/mining-admin-web/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/mining-admin-web/src/app/(dashboard)/dashboard/page.tsx @@ -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() { + {/* 挖矿进度 */} +
+ +
+ {/* 详细算力分解 */} diff --git a/frontend/mining-admin-web/src/features/mining/api/mining.api.ts b/frontend/mining-admin-web/src/features/mining/api/mining.api.ts new file mode 100644 index 00000000..a0b95b13 --- /dev/null +++ b/frontend/mining-admin-web/src/features/mining/api/mining.api.ts @@ -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 => { + const response = await miningClient.get('/admin/mining/status'); + // 后端返回 { success, data, timestamp } + return response.data.data || response.data; + }, +}; diff --git a/frontend/mining-admin-web/src/features/mining/components/mining-progress.tsx b/frontend/mining-admin-web/src/features/mining/components/mining-progress.tsx new file mode 100644 index 00000000..ca593386 --- /dev/null +++ b/frontend/mining-admin-web/src/features/mining/components/mining-progress.tsx @@ -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 ( + + +
+ + + 挖矿进度 + + {isLoading ? ( + + ) : miningStatus?.isActive ? ( + + + 运行中 + + ) : ( + + + 已停用 + + )} +
+
+ + {isLoading ? ( + + ) : !miningStatus ? ( +
+

无法获取挖矿数据

+
+ ) : ( +
+
+
+ 挖矿进度 + {formatNumber(miningStatus.miningProgress, 4)}% +
+
+
+
+
+
+
+

已分配

+

{formatNumber(miningStatus.totalDistributed, 8)}

+
+
+

剩余分配池

+

{formatNumber(miningStatus.remainingDistribution, 8)}

+
+
+

剩余分钟

+

{miningStatus.remainingMinutes?.toLocaleString()}

+
+
+

每分钟分配

+

{formatNumber(miningStatus.minuteDistribution, 8)}

+
+
+ {miningStatus.lastMiningMinute && ( +

+ 最后分配: {new Date(miningStatus.lastMiningMinute).toLocaleString()} +

+ )} +
+ )} + + + ); +} diff --git a/frontend/mining-admin-web/src/features/mining/hooks/use-mining.ts b/frontend/mining-admin-web/src/features/mining/hooks/use-mining.ts new file mode 100644 index 00000000..c58db720 --- /dev/null +++ b/frontend/mining-admin-web/src/features/mining/hooks/use-mining.ts @@ -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秒内数据视为新鲜 + }); +}