feat(mining-admin): 配置管理新增100亿销毁池和200万挖矿池账户

在配置管理页面新增两个池账户卡片,UI 风格对齐做市商管理中的
"现金余额(积分值)",仅支持区块链方式充值与提现:

- 100亿销毁池 (wallet-22fd661f, 2-of-3 门限)
  地址: 0xdE2932D2A25e1698c1354A41e2e46B414C46F5a1
- 200万挖矿池 (wallet-974e78f5, 2-of-3 门限)
  地址: 0x8BC9091375ae8ef43ae011F0f9bAf10e51bC9D59

具体改动:
- .env.production: 新增 BURN_POOL / MINING_POOL 钱包名和地址环境变量
- configs.api.ts: 新增 tradingClient、PoolAccountBalance 接口、
  getPoolAccountBalance 和 poolAccountBlockchainWithdraw API
- use-configs.ts: 新增 usePoolAccountBalance (30s 刷新) 和
  usePoolAccountBlockchainWithdraw hooks
- configs/page.tsx: 新增两个并排池账户 Card,包含余额展示、
  QR 码充值弹窗、区块链提现弹窗(含 0x 地址校验)

后端需实现:
- GET  /admin/pool-accounts/:walletName/balance
- POST /admin/pool-accounts/:walletName/blockchain-withdraw

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-03 00:02:32 -08:00
parent 999d0389b3
commit 6dbb620e82
4 changed files with 435 additions and 3 deletions

View File

@ -1,3 +1,11 @@
NEXT_PUBLIC_API_URL=https://mapi.szaiai.com/api/v2/mining-admin
TRADING_SERVICE_URL=https://mapi.szaiai.com/api/v2/trading
NEXT_PUBLIC_APP_NAME=挖矿管理后台
# 100亿销毁池钱包配置
NEXT_PUBLIC_BURN_POOL_WALLET_NAME=wallet-22fd661f
NEXT_PUBLIC_BURN_POOL_WALLET_ADDRESS=0xdE2932D2A25e1698c1354A41e2e46B414C46F5a1
# 200万挖矿池钱包配置
NEXT_PUBLIC_MINING_POOL_WALLET_NAME=wallet-974e78f5
NEXT_PUBLIC_MINING_POOL_WALLET_ADDRESS=0x8BC9091375ae8ef43ae011F0f9bAf10e51bC9D59

View File

@ -1,20 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { PageHeader } from '@/components/layout/page-header';
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining, useP2pTransferFee, useSetP2pTransferFee } from '@/features/configs/hooks/use-configs';
import { useConfigs, useUpdateConfig, useTransferEnabled, useSetTransferEnabled, useMiningStatus, useActivateMining, useDeactivateMining, useP2pTransferFee, useSetP2pTransferFee, usePoolAccountBalance, usePoolAccountBlockchainWithdraw } from '@/features/configs/hooks/use-configs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Pencil, Save, X, Play, Pause, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
import { Pencil, Save, X, Play, Pause, AlertCircle, CheckCircle2, Loader2, Wallet, PlusCircle, MinusCircle, Copy, Check, Flame, HardHat } from 'lucide-react';
import type { SystemConfig } from '@/types/config';
const BURN_POOL_WALLET_NAME = process.env.NEXT_PUBLIC_BURN_POOL_WALLET_NAME || 'wallet-22fd661f';
const BURN_POOL_WALLET_ADDRESS = process.env.NEXT_PUBLIC_BURN_POOL_WALLET_ADDRESS || '0xdE2932D2A25e1698c1354A41e2e46B414C46F5a1';
const MINING_POOL_WALLET_NAME = process.env.NEXT_PUBLIC_MINING_POOL_WALLET_NAME || 'wallet-974e78f5';
const MINING_POOL_WALLET_ADDRESS = process.env.NEXT_PUBLIC_MINING_POOL_WALLET_ADDRESS || '0x8BC9091375ae8ef43ae011F0f9bAf10e51bC9D59';
const categoryLabels: Record<string, string> = {
mining: '挖矿配置',
trading: '交易配置',
@ -34,11 +48,22 @@ export default function ConfigsPage() {
const { data: feeConfig, isLoading: feeLoading } = useP2pTransferFee();
const setP2pTransferFee = useSetP2pTransferFee();
const { data: burnPoolBalance, isLoading: burnPoolLoading } = usePoolAccountBalance(BURN_POOL_WALLET_NAME);
const { data: miningPoolBalance, isLoading: miningPoolLoading } = usePoolAccountBalance(MINING_POOL_WALLET_NAME);
const blockchainWithdrawMutation = usePoolAccountBlockchainWithdraw();
const [editingConfig, setEditingConfig] = useState<SystemConfig | null>(null);
const [editValue, setEditValue] = useState('');
const [feeValue, setFeeValue] = useState('');
const [minAmountValue, setMinAmountValue] = useState('');
// 池账户区块链提现状态
const [burnWithdrawAddress, setBurnWithdrawAddress] = useState('');
const [burnWithdrawAmount, setBurnWithdrawAmount] = useState('');
const [miningWithdrawAddress, setMiningWithdrawAddress] = useState('');
const [miningWithdrawAmount, setMiningWithdrawAmount] = useState('');
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
useEffect(() => {
if (feeConfig) {
setFeeValue(feeConfig.fee);
@ -46,6 +71,19 @@ export default function ConfigsPage() {
}
}, [feeConfig]);
const handleCopyAddress = async (address: string) => {
await navigator.clipboard.writeText(address);
setCopiedAddress(address);
setTimeout(() => setCopiedAddress(null), 2000);
};
const formatBalance = (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 });
};
const handleEdit = (config: SystemConfig) => {
setEditingConfig(config);
setEditValue(config.configValue);
@ -194,6 +232,291 @@ export default function ConfigsPage() {
</CardContent>
</Card>
{/* 池账户管理 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 100亿销毁池 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-500" />
100亿
</CardTitle>
<Badge variant="outline" className="text-xs">
2-of-3
</Badge>
</div>
<CardDescription>
: {BURN_POOL_WALLET_NAME}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{burnPoolLoading ? (
<Skeleton className="h-20 w-full" />
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatBalance(burnPoolBalance?.balance)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-orange-500">{formatBalance(burnPoolBalance?.availableBalance)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg text-muted-foreground">{formatBalance(burnPoolBalance?.frozenBalance)}</p>
</div>
</div>
)}
<div className="flex gap-2 pt-4 border-t">
{/* 区块链充值 */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<PlusCircle className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 100亿</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={BURN_POOL_WALLET_ADDRESS} size={180} />
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{BURN_POOL_WALLET_ADDRESS}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(BURN_POOL_WALLET_ADDRESS)}
>
{copiedAddress === BURN_POOL_WALLET_ADDRESS ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
</DialogContent>
</Dialog>
{/* 区块链提现 */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<MinusCircle className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 100亿</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> (Kava EVM)</Label>
<Input
value={burnWithdrawAddress}
onChange={(e) => setBurnWithdrawAddress(e.target.value)}
placeholder="0x..."
/>
</div>
<div>
<Label></Label>
<Input
type="number"
value={burnWithdrawAmount}
onChange={(e) => setBurnWithdrawAmount(e.target.value)}
placeholder="请输入金额"
/>
</div>
<Button
className="w-full"
onClick={() => {
blockchainWithdrawMutation.mutate({
walletName: BURN_POOL_WALLET_NAME,
toAddress: burnWithdrawAddress,
amount: burnWithdrawAmount,
});
setBurnWithdrawAddress('');
setBurnWithdrawAmount('');
}}
disabled={
blockchainWithdrawMutation.isPending ||
!burnWithdrawAddress ||
!burnWithdrawAmount ||
!burnWithdrawAddress.startsWith('0x')
}
>
{blockchainWithdrawMutation.isPending ? '链上转账中...' : '确认区块链提现'}
</Button>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded">
<AlertCircle className="h-3 w-3 inline mr-1" />
2-of-3
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
{/* 200万挖矿池 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<HardHat className="h-5 w-5 text-blue-500" />
200
</CardTitle>
<Badge variant="outline" className="text-xs">
2-of-3
</Badge>
</div>
<CardDescription>
: {MINING_POOL_WALLET_NAME}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{miningPoolLoading ? (
<Skeleton className="h-20 w-full" />
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatBalance(miningPoolBalance?.balance)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-blue-500">{formatBalance(miningPoolBalance?.availableBalance)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg text-muted-foreground">{formatBalance(miningPoolBalance?.frozenBalance)}</p>
</div>
</div>
)}
<div className="flex gap-2 pt-4 border-t">
{/* 区块链充值 */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<PlusCircle className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 200</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center space-y-4">
<div className="p-4 bg-white rounded-lg">
<QRCodeSVG value={MINING_POOL_WALLET_ADDRESS} size={180} />
</div>
<div className="w-full">
<Label className="text-xs text-muted-foreground"> (Kava EVM)</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
{MINING_POOL_WALLET_ADDRESS}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyAddress(MINING_POOL_WALLET_ADDRESS)}
>
{copiedAddress === MINING_POOL_WALLET_ADDRESS ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded w-full">
<AlertCircle className="h-3 w-3 inline mr-1" />
12
</div>
</div>
</DialogContent>
</Dialog>
{/* 区块链提现 */}
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<MinusCircle className="h-4 w-4 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> 200</DialogTitle>
<DialogDescription> fUSDT</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> (Kava EVM)</Label>
<Input
value={miningWithdrawAddress}
onChange={(e) => setMiningWithdrawAddress(e.target.value)}
placeholder="0x..."
/>
</div>
<div>
<Label></Label>
<Input
type="number"
value={miningWithdrawAmount}
onChange={(e) => setMiningWithdrawAmount(e.target.value)}
placeholder="请输入金额"
/>
</div>
<Button
className="w-full"
onClick={() => {
blockchainWithdrawMutation.mutate({
walletName: MINING_POOL_WALLET_NAME,
toAddress: miningWithdrawAddress,
amount: miningWithdrawAmount,
});
setMiningWithdrawAddress('');
setMiningWithdrawAmount('');
}}
disabled={
blockchainWithdrawMutation.isPending ||
!miningWithdrawAddress ||
!miningWithdrawAmount ||
!miningWithdrawAddress.startsWith('0x')
}
>
{blockchainWithdrawMutation.isPending ? '链上转账中...' : '确认区块链提现'}
</Button>
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded">
<AlertCircle className="h-3 w-3 inline mr-1" />
2-of-3
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>

View File

@ -1,12 +1,62 @@
import { apiClient } from '@/lib/api/client';
import axios from 'axios';
import type { SystemConfig } from '@/types/config';
const tradingBaseURL = '/api/trading';
const tradingClient = axios.create({
baseURL: tradingBaseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
tradingClient.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)
);
tradingClient.interceptors.response.use(
(response) => {
if (response.data && response.data.data !== undefined) {
response.data = response.data.data;
}
return 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 ContributionSyncStatus {
isSynced: boolean;
miningNetworkTotal: string;
networkTotalContribution: string;
}
export interface PoolAccountBalance {
walletName: string;
walletAddress: string;
balance: string;
availableBalance: string;
frozenBalance: string;
threshold: string;
lastUpdated?: string;
}
export interface MiningStatus {
initialized: boolean;
isActive: boolean;
@ -63,4 +113,23 @@ export const configsApi = {
setP2pTransferFee: async (fee: string, minTransferAmount: string): Promise<void> => {
await apiClient.post('/configs/p2p-transfer-fee', { fee, minTransferAmount });
},
// 获取池账户余额
getPoolAccountBalance: async (walletName: string): Promise<PoolAccountBalance> => {
const response = await tradingClient.get(`/admin/pool-accounts/${walletName}/balance`);
return response.data;
},
// 区块链提现(从池账户)
poolAccountBlockchainWithdraw: async (walletName: string, toAddress: string, amount: string): Promise<{
success: boolean;
message: string;
txHash?: string;
blockNumber?: number;
newBalance?: string;
error?: string;
}> => {
const response = await tradingClient.post(`/admin/pool-accounts/${walletName}/blockchain-withdraw`, { toAddress, amount });
return response.data;
},
};

View File

@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { configsApi } from '../api/configs.api';
import { useToast } from '@/lib/hooks/use-toast';
import { toast } from '@/lib/hooks/use-toast';
export function useConfigs() {
return useQuery({
@ -119,3 +120,34 @@ export function useSetP2pTransferFee() {
},
});
}
export function usePoolAccountBalance(walletName: string) {
return useQuery({
queryKey: ['configs', 'pool-account', walletName],
queryFn: () => configsApi.getPoolAccountBalance(walletName),
refetchInterval: 30000,
enabled: !!walletName,
});
}
export function usePoolAccountBlockchainWithdraw() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ walletName, toAddress, amount }: { walletName: string; toAddress: string; amount: string }) =>
configsApi.poolAccountBlockchainWithdraw(walletName, toAddress, amount),
onSuccess: (data) => {
if (data.success) {
toast({
title: '区块链提现成功',
description: `交易哈希: ${data.txHash?.slice(0, 20)}...`,
});
} else {
toast({ title: '提现失败', description: data.error || data.message, variant: 'destructive' });
}
queryClient.invalidateQueries({ queryKey: ['configs', 'pool-account'] });
},
onError: (error: any) => {
toast({ title: '错误', description: error.response?.data?.message || '区块链提现失败', variant: 'destructive' });
},
});
}