feat(c2c-bot): 添加C2C Bot管理页面,支持运行时开关和热钱包查看
- 新增C2C Bot管理页面(mining-admin-web):Bot开关、热钱包余额/地址/QR码、统计、订单历史 - 新增admin API端点(trading-service):status/enable/disable/orders - 重构Bot调度器enabled为Redis驱动,支持运行时开关(多实例安全) - C2cOrderRepository新增findBotPurchasedOrders和getBotStats查询方法 - 侧边栏添加C2C Bot导航入口 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bf772967f5
commit
251e37f772
|
|
@ -11,6 +11,7 @@ import { BurnController } from './controllers/burn.controller';
|
||||||
import { AssetController } from './controllers/asset.controller';
|
import { AssetController } from './controllers/asset.controller';
|
||||||
import { MarketMakerController } from './controllers/market-maker.controller';
|
import { MarketMakerController } from './controllers/market-maker.controller';
|
||||||
import { C2cController } from './controllers/c2c.controller';
|
import { C2cController } from './controllers/c2c.controller';
|
||||||
|
import { C2cBotController } from './controllers/c2c-bot.controller';
|
||||||
import { PriceGateway } from './gateways/price.gateway';
|
import { PriceGateway } from './gateways/price.gateway';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -26,6 +27,7 @@ import { PriceGateway } from './gateways/price.gateway';
|
||||||
AssetController,
|
AssetController,
|
||||||
MarketMakerController,
|
MarketMakerController,
|
||||||
C2cController,
|
C2cController,
|
||||||
|
C2cBotController,
|
||||||
],
|
],
|
||||||
providers: [PriceGateway],
|
providers: [PriceGateway],
|
||||||
exports: [PriceGateway],
|
exports: [PriceGateway],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { C2cBotService } from '../../application/services/c2c-bot.service';
|
||||||
|
import { C2cBotScheduler } from '../../application/schedulers/c2c-bot.scheduler';
|
||||||
|
import { C2cOrderRepository } from '../../infrastructure/persistence/repositories/c2c-order.repository';
|
||||||
|
import { BlockchainClient } from '../../infrastructure/blockchain/blockchain.client';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('C2C Bot Admin')
|
||||||
|
@Controller('admin/c2c-bot')
|
||||||
|
export class C2cBotController {
|
||||||
|
constructor(
|
||||||
|
private readonly c2cBotService: C2cBotService,
|
||||||
|
private readonly c2cOrderRepository: C2cOrderRepository,
|
||||||
|
private readonly blockchainClient: BlockchainClient,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取 C2C Bot 状态' })
|
||||||
|
async getStatus() {
|
||||||
|
const [enabled, blockchainAvailable, balance, status, stats] = await Promise.all([
|
||||||
|
this.redis.get(C2cBotScheduler.ENABLED_KEY).then((v) => v === 'true'),
|
||||||
|
this.c2cBotService.isAvailable(),
|
||||||
|
this.c2cBotService.getHotWalletBalance(),
|
||||||
|
this.blockchainClient.getStatus(),
|
||||||
|
this.c2cOrderRepository.getBotStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
enabled,
|
||||||
|
blockchainAvailable,
|
||||||
|
hotWallet: {
|
||||||
|
address: status?.hotWalletAddress ?? null,
|
||||||
|
balance: balance ?? null,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalBotOrders: stats.totalOrders,
|
||||||
|
totalBotAmount: stats.totalAmount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('enable')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '开启 C2C Bot' })
|
||||||
|
async enable() {
|
||||||
|
await this.redis.set(C2cBotScheduler.ENABLED_KEY, 'true');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
enabled: true,
|
||||||
|
message: 'C2C Bot 已开启',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('disable')
|
||||||
|
@Public()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '关闭 C2C Bot' })
|
||||||
|
async disable() {
|
||||||
|
await this.redis.set(C2cBotScheduler.ENABLED_KEY, 'false');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
enabled: false,
|
||||||
|
message: 'C2C Bot 已关闭',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('orders')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({ summary: '获取 Bot 购买的订单历史' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
async getOrders(
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('pageSize') pageSize?: number,
|
||||||
|
) {
|
||||||
|
const result = await this.c2cOrderRepository.findBotPurchasedOrders({
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: pageSize ?? 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.data.map((order) => ({
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
makerAccountSequence: order.makerAccountSequence,
|
||||||
|
makerPhone: order.makerPhone,
|
||||||
|
makerNickname: order.makerNickname,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
price: order.price,
|
||||||
|
quantity: order.quantity,
|
||||||
|
sellerKavaAddress: order.sellerKavaAddress,
|
||||||
|
paymentTxHash: order.paymentTxHash,
|
||||||
|
status: order.status,
|
||||||
|
completedAt: order.completedAt,
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: pageSize ?? 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,21 +13,27 @@ import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
export class C2cBotScheduler implements OnModuleInit {
|
export class C2cBotScheduler implements OnModuleInit {
|
||||||
private readonly logger = new Logger(C2cBotScheduler.name);
|
private readonly logger = new Logger(C2cBotScheduler.name);
|
||||||
private readonly LOCK_KEY = 'c2c:bot:scheduler:lock';
|
private readonly LOCK_KEY = 'c2c:bot:scheduler:lock';
|
||||||
private readonly enabled: boolean;
|
static readonly ENABLED_KEY = 'c2c:bot:enabled';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly c2cOrderRepository: C2cOrderRepository,
|
private readonly c2cOrderRepository: C2cOrderRepository,
|
||||||
private readonly c2cBotService: C2cBotService,
|
private readonly c2cBotService: C2cBotService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {}
|
||||||
this.enabled = this.configService.get<boolean>('C2C_BOT_ENABLED', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log(`C2C Bot Scheduler initialized, enabled: ${this.enabled}`);
|
// 如果 Redis 中没有设置,用环境变量初始化
|
||||||
|
const existing = await this.redis.get(C2cBotScheduler.ENABLED_KEY);
|
||||||
|
if (existing === null) {
|
||||||
|
const envEnabled = this.configService.get<string>('C2C_BOT_ENABLED', 'false');
|
||||||
|
await this.redis.set(C2cBotScheduler.ENABLED_KEY, envEnabled === 'true' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.enabled) {
|
const enabled = await this.isEnabled();
|
||||||
|
this.logger.log(`C2C Bot Scheduler initialized, enabled: ${enabled}`);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
const isAvailable = await this.c2cBotService.isAvailable();
|
const isAvailable = await this.c2cBotService.isAvailable();
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
const balance = await this.c2cBotService.getHotWalletBalance();
|
const balance = await this.c2cBotService.getHotWalletBalance();
|
||||||
|
|
@ -38,12 +44,23 @@ export class C2cBotScheduler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Bot 是否启用(读 Redis,回退到环境变量)
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
const value = await this.redis.get(C2cBotScheduler.ENABLED_KEY);
|
||||||
|
if (value === null) {
|
||||||
|
return this.configService.get<string>('C2C_BOT_ENABLED', 'false') === 'true';
|
||||||
|
}
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每10秒扫描待处理的卖单
|
* 每10秒扫描待处理的卖单
|
||||||
*/
|
*/
|
||||||
@Cron('*/10 * * * * *')
|
@Cron('*/10 * * * * *')
|
||||||
async processPendingSellOrders(): Promise<void> {
|
async processPendingSellOrders(): Promise<void> {
|
||||||
if (!this.enabled) {
|
if (!(await this.isEnabled())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +112,7 @@ export class C2cBotScheduler implements OnModuleInit {
|
||||||
*/
|
*/
|
||||||
@Cron('0 * * * * *')
|
@Cron('0 * * * * *')
|
||||||
async checkHotWalletBalance(): Promise<void> {
|
async checkHotWalletBalance(): Promise<void> {
|
||||||
if (!this.enabled) {
|
if (!(await this.isEnabled())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,46 @@ export class C2cOrderRepository {
|
||||||
return this.toEntity(record);
|
return this.toEntity(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 Bot 购买的订单(分页)
|
||||||
|
*/
|
||||||
|
async findBotPurchasedOrders(options: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}): Promise<{ data: C2cOrderEntity[]; total: number }> {
|
||||||
|
const where = { botPurchased: true };
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
this.prisma.c2cOrder.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { completedAt: 'desc' },
|
||||||
|
skip: (options.page - 1) * options.pageSize,
|
||||||
|
take: options.pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.c2cOrder.count({ where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
data: records.map((r: any) => this.toEntity(r)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Bot 统计数据(总订单数、总金额)
|
||||||
|
*/
|
||||||
|
async getBotStats(): Promise<{ totalOrders: number; totalAmount: string }> {
|
||||||
|
const [count, sum] = await Promise.all([
|
||||||
|
this.prisma.c2cOrder.count({ where: { botPurchased: true } }),
|
||||||
|
this.prisma.c2cOrder.aggregate({
|
||||||
|
where: { botPurchased: true },
|
||||||
|
_sum: { totalAmount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
totalOrders: count,
|
||||||
|
totalAmount: sum._sum.totalAmount?.toString() || '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将Prisma记录转为实体
|
* 将Prisma记录转为实体
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import {
|
||||||
|
useC2cBotStatus,
|
||||||
|
useEnableC2cBot,
|
||||||
|
useDisableC2cBot,
|
||||||
|
useC2cBotOrders,
|
||||||
|
} from '@/features/c2c-bot/hooks/use-c2c-bot';
|
||||||
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Wallet,
|
||||||
|
BarChart3,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
function formatNumber(value: string | undefined | null, decimals: number = 2) {
|
||||||
|
if (!value) return '0';
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num)) return '0';
|
||||||
|
return num.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAddress(addr: string | null | undefined, chars: number = 8) {
|
||||||
|
if (!addr) return '-';
|
||||||
|
if (addr.length <= chars * 2 + 2) return addr;
|
||||||
|
return `${addr.slice(0, chars + 2)}...${addr.slice(-chars)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function C2cBotPage() {
|
||||||
|
const [orderPage, setOrderPage] = useState(1);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const { data: status, isLoading: statusLoading, refetch } = useC2cBotStatus();
|
||||||
|
const { data: ordersData, isLoading: ordersLoading } = useC2cBotOrders(orderPage, pageSize);
|
||||||
|
const enableMutation = useEnableC2cBot();
|
||||||
|
const disableMutation = useDisableC2cBot();
|
||||||
|
|
||||||
|
const totalPages = ordersData ? Math.ceil(ordersData.total / pageSize) : 0;
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
enableMutation.mutate();
|
||||||
|
} else {
|
||||||
|
disableMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyAddress = () => {
|
||||||
|
if (status?.hotWallet?.address) {
|
||||||
|
navigator.clipboard.writeText(status.hotWallet.address);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="C2C Bot 管理" description="管理 C2C 自动购买机器人" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
<Skeleton className="h-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<PageHeader title="C2C Bot 管理" description="管理 C2C 自动购买机器人" />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态卡片 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Bot 开关 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
Bot 状态
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>自动购买用户卖单</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">运行状态</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{status?.enabled ? (
|
||||||
|
<Badge className="bg-green-500">运行中</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">已停止</Badge>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
checked={status?.enabled ?? false}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
disabled={enableMutation.isPending || disableMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">区块链服务</span>
|
||||||
|
{status?.blockchainAvailable ? (
|
||||||
|
<div className="flex items-center gap-1 text-green-600">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span className="text-sm">可用</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-red-500">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">不可用</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground bg-muted p-2 rounded">
|
||||||
|
Bot 每 10 秒扫描一次待处理卖单,自动转账 dUSDT 完成购买
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 热钱包 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Wallet className="h-5 w-5" />
|
||||||
|
热钱包
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Bot 使用的 dUSDT 钱包</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">dUSDT 余额</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(status?.hotWallet?.balance, 4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{status?.hotWallet?.address ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">钱包地址 (Kava EVM)</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-muted p-2 rounded break-all">
|
||||||
|
{status.hotWallet.address}
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={handleCopyAddress}>
|
||||||
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="p-3 bg-white rounded-lg">
|
||||||
|
<QRCodeSVG value={status.hotWallet.address} size={120} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-yellow-600 bg-yellow-50 p-2 rounded">
|
||||||
|
<AlertCircle className="h-3 w-3 inline mr-1" />
|
||||||
|
向此地址转入 dUSDT (Kava链) 即可为 Bot 充值
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">热钱包未配置</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 统计 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
统计
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Bot 自动购买统计</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">总成交订单</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{status?.stats?.totalBotOrders ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">总成交金额</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(status?.stats?.totalBotAmount, 4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 订单历史 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
Bot 订单历史
|
||||||
|
{ordersData && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
共 {ordersData.total} 条
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{ordersLoading ? (
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
) : ordersData && ordersData.data.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>订单号</TableHead>
|
||||||
|
<TableHead>卖家</TableHead>
|
||||||
|
<TableHead className="text-right">金额</TableHead>
|
||||||
|
<TableHead className="text-right">数量</TableHead>
|
||||||
|
<TableHead>Kava 地址</TableHead>
|
||||||
|
<TableHead>TxHash</TableHead>
|
||||||
|
<TableHead>完成时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ordersData.data.map((order) => (
|
||||||
|
<TableRow key={order.orderNo}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{order.orderNo}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
{order.makerNickname || order.makerPhone || order.makerAccountSequence}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatNumber(order.totalAmount, 4)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatNumber(order.quantity, 4)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{truncateAddress(order.sellerKavaAddress, 6)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{order.paymentTxHash ? (
|
||||||
|
<span className="text-blue-600" title={order.paymentTxHash}>
|
||||||
|
{truncateAddress(order.paymentTxHash, 6)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{order.completedAt
|
||||||
|
? format(new Date(order.completedAt), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
暂无 Bot 订单记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOrderPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={orderPage <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground px-4">
|
||||||
|
第 {orderPage} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOrderPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={orderPage >= totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Bot,
|
Bot,
|
||||||
|
Zap,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
|
|
@ -27,6 +28,7 @@ const menuItems = [
|
||||||
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
||||||
{ name: 'P2P划转', href: '/p2p-transfers', icon: SendHorizontal },
|
{ name: 'P2P划转', href: '/p2p-transfers', icon: SendHorizontal },
|
||||||
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
||||||
|
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
|
||||||
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||||
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
||||||
{ name: '配置管理', href: '/configs', icon: Settings },
|
{ name: '配置管理', href: '/configs', icon: Settings },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const tradingClient = axios.create({
|
||||||
|
baseURL: '/api/trading',
|
||||||
|
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 C2cBotStatus {
|
||||||
|
success: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
blockchainAvailable: boolean;
|
||||||
|
hotWallet: {
|
||||||
|
address: string | null;
|
||||||
|
balance: string | null;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
totalBotOrders: number;
|
||||||
|
totalBotAmount: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotOrder {
|
||||||
|
orderNo: string;
|
||||||
|
makerAccountSequence: string;
|
||||||
|
makerPhone: string | null;
|
||||||
|
makerNickname: string | null;
|
||||||
|
totalAmount: string;
|
||||||
|
price: string;
|
||||||
|
quantity: string;
|
||||||
|
sellerKavaAddress: string | null;
|
||||||
|
paymentTxHash: string | null;
|
||||||
|
status: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotOrdersResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: BotOrder[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const c2cBotApi = {
|
||||||
|
getStatus: async (): Promise<C2cBotStatus> => {
|
||||||
|
const response = await tradingClient.get('/admin/c2c-bot/status');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
enable: async (): Promise<{ success: boolean; enabled: boolean; message: string }> => {
|
||||||
|
const response = await tradingClient.post('/admin/c2c-bot/enable');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
disable: async (): Promise<{ success: boolean; enabled: boolean; message: string }> => {
|
||||||
|
const response = await tradingClient.post('/admin/c2c-bot/disable');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrders: async (page: number = 1, pageSize: number = 20): Promise<BotOrdersResponse> => {
|
||||||
|
const response = await tradingClient.get('/admin/c2c-bot/orders', {
|
||||||
|
params: { page, pageSize },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { c2cBotApi } from '../api/c2c-bot.api';
|
||||||
|
import { toast } from '@/lib/hooks/use-toast';
|
||||||
|
|
||||||
|
export function useC2cBotStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['c2cBot', 'status'],
|
||||||
|
queryFn: () => c2cBotApi.getStatus(),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnableC2cBot() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => c2cBotApi.enable(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({ title: '成功', description: data.message });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['c2cBot'] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({ title: '错误', description: error.response?.data?.message || '开启失败', variant: 'destructive' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisableC2cBot() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => c2cBotApi.disable(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({ title: '成功', description: data.message });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['c2cBot'] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({ title: '错误', description: error.response?.data?.message || '关闭失败', variant: 'destructive' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useC2cBotOrders(page: number = 1, pageSize: number = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['c2cBot', 'orders', page, pageSize],
|
||||||
|
queryFn: () => c2cBotApi.getOrders(page, pageSize),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue