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 { MarketMakerController } from './controllers/market-maker.controller';
|
||||
import { C2cController } from './controllers/c2c.controller';
|
||||
import { C2cBotController } from './controllers/c2c-bot.controller';
|
||||
import { PriceGateway } from './gateways/price.gateway';
|
||||
|
||||
@Module({
|
||||
|
|
@ -26,6 +27,7 @@ import { PriceGateway } from './gateways/price.gateway';
|
|||
AssetController,
|
||||
MarketMakerController,
|
||||
C2cController,
|
||||
C2cBotController,
|
||||
],
|
||||
providers: [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 {
|
||||
private readonly logger = new Logger(C2cBotScheduler.name);
|
||||
private readonly LOCK_KEY = 'c2c:bot:scheduler:lock';
|
||||
private readonly enabled: boolean;
|
||||
static readonly ENABLED_KEY = 'c2c:bot:enabled';
|
||||
|
||||
constructor(
|
||||
private readonly c2cOrderRepository: C2cOrderRepository,
|
||||
private readonly c2cBotService: C2cBotService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.enabled = this.configService.get<boolean>('C2C_BOT_ENABLED', false);
|
||||
}
|
||||
) {}
|
||||
|
||||
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();
|
||||
if (isAvailable) {
|
||||
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秒扫描待处理的卖单
|
||||
*/
|
||||
@Cron('*/10 * * * * *')
|
||||
async processPendingSellOrders(): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
if (!(await this.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +112,7 @@ export class C2cBotScheduler implements OnModuleInit {
|
|||
*/
|
||||
@Cron('0 * * * * *')
|
||||
async checkHotWalletBalance(): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
if (!(await this.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -347,6 +347,46 @@ export class C2cOrderRepository {
|
|||
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记录转为实体
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
ArrowLeftRight,
|
||||
Bot,
|
||||
Zap,
|
||||
HandCoins,
|
||||
FileSpreadsheet,
|
||||
SendHorizontal,
|
||||
|
|
@ -27,6 +28,7 @@ const menuItems = [
|
|||
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
|
||||
{ name: 'P2P划转', href: '/p2p-transfers', icon: SendHorizontal },
|
||||
{ name: '做市商管理', href: '/market-maker', icon: Bot },
|
||||
{ name: 'C2C Bot', href: '/c2c-bot', icon: Zap },
|
||||
{ name: '手工补发', href: '/manual-mining', icon: HandCoins },
|
||||
{ name: '批量补发', href: '/batch-mining', icon: FileSpreadsheet },
|
||||
{ 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