feat(admin): add trading system management UI and API

- Add trading system activate/deactivate endpoints to trading-service
- Add trading management page to mining-admin-web with:
  - Trading system status display and control
  - Market overview (price, green points, circulation pool)
  - Burn progress visualization
  - Burn records list with filtering
- Add trading-service proxy configuration to next.config.js
- Add trading menu item to sidebar navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-15 20:37:52 -08:00
parent 1e2d8d1df7
commit 8018fa5110
8 changed files with 658 additions and 3 deletions

View File

@ -1,12 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Get, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository';
import { Public } from '../../shared/guards/jwt-auth.guard';
@ApiTags('Admin')
@Controller('admin')
export class AdminController {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly tradingConfigRepository: TradingConfigRepository,
) {}
@Get('accounts/sync')
@Public()
@ -41,4 +45,93 @@ export class AdminController {
total: accounts.length,
};
}
@Get('trading/status')
@Public()
@ApiOperation({ summary: '获取交易系统状态' })
@ApiResponse({ status: 200, description: '返回交易系统配置状态' })
async getTradingStatus() {
const config = await this.tradingConfigRepository.getConfig();
if (!config) {
return {
initialized: false,
isActive: false,
activatedAt: null,
message: '交易系统未初始化',
};
}
return {
initialized: true,
isActive: config.isActive,
activatedAt: config.activatedAt,
totalShares: config.totalShares.toFixed(8),
burnTarget: config.burnTarget.toFixed(8),
burnPeriodMinutes: config.burnPeriodMinutes,
minuteBurnRate: config.minuteBurnRate.toFixed(18),
message: config.isActive ? '交易系统已激活' : '交易系统未激活',
};
}
@Post('trading/activate')
@Public() // TODO: 生产环境应添加管理员权限验证
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '激活交易/销毁系统' })
@ApiResponse({ status: 200, description: '交易系统已激活' })
@ApiResponse({ status: 400, description: '交易系统未初始化' })
async activateTrading() {
const config = await this.tradingConfigRepository.getConfig();
if (!config) {
return {
success: false,
message: '交易系统未初始化,请先启动服务',
};
}
if (config.isActive) {
return {
success: true,
message: '交易系统已处于激活状态',
activatedAt: config.activatedAt,
};
}
await this.tradingConfigRepository.activate();
const updatedConfig = await this.tradingConfigRepository.getConfig();
return {
success: true,
message: '交易系统已激活,每分钟销毁已开始运行',
activatedAt: updatedConfig?.activatedAt,
};
}
@Post('trading/deactivate')
@Public() // TODO: 生产环境应添加管理员权限验证
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '关闭交易/销毁系统' })
@ApiResponse({ status: 200, description: '交易系统已关闭' })
async deactivateTrading() {
const config = await this.tradingConfigRepository.getConfig();
if (!config) {
return {
success: false,
message: '交易系统未初始化',
};
}
if (!config.isActive) {
return {
success: true,
message: '交易系统已处于关闭状态',
};
}
await this.tradingConfigRepository.deactivate();
return {
success: true,
message: '交易系统已关闭,每分钟销毁已暂停',
};
}
}

View File

@ -1,2 +1,3 @@
NEXT_PUBLIC_API_URL=https://rwaapi.szaiai.com/api/v2/mining-admin
TRADING_SERVICE_URL=https://rwaapi.szaiai.com/api/v2/trading
NEXT_PUBLIC_APP_NAME=挖矿管理后台

View File

@ -6,9 +6,17 @@ const nextConfig = {
// NEXT_PUBLIC_API_URL 应该是后端服务的基础 URL如 http://mining-admin-service:3023
// 前端请求 /api/xxx 会被转发到 {API_URL}/api/v2/xxx
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023';
const tradingServiceUrl = process.env.TRADING_SERVICE_URL || 'http://localhost:3020';
// 移除末尾可能存在的 /api/v2 避免重复
const cleanUrl = apiBaseUrl.replace(/\/api\/v2\/?$/, '');
const cleanTradingUrl = tradingServiceUrl.replace(/\/api\/v2\/?$/, '');
return [
// trading-service 路由
{
source: '/api/trading/:path*',
destination: `${cleanTradingUrl}/api/v2/:path*`,
},
// mining-admin-service 路由 (默认)
{
source: '/api/:path*',
destination: `${cleanUrl}/api/v2/:path*`,

View File

@ -0,0 +1,373 @@
'use client';
import { useState } from 'react';
import { PageHeader } from '@/components/layout/page-header';
import {
useTradingStatus,
useActivateTrading,
useDeactivateTrading,
useBurnStatus,
useBurnRecords,
useMarketOverview,
} from '@/features/trading/hooks/use-trading';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Play,
Pause,
AlertCircle,
CheckCircle2,
TrendingUp,
Flame,
DollarSign,
Activity,
RefreshCw,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
export default function TradingPage() {
const { data: tradingStatus, isLoading: tradingLoading, refetch: refetchTrading } = useTradingStatus();
const { data: burnStatus, isLoading: burnLoading, refetch: refetchBurn } = useBurnStatus();
const { data: marketOverview, isLoading: marketLoading, refetch: refetchMarket } = useMarketOverview();
const activateTrading = useActivateTrading();
const deactivateTrading = useDeactivateTrading();
const [recordsPage, setRecordsPage] = useState(1);
const [recordsFilter, setRecordsFilter] = useState<'ALL' | 'MINUTE_BURN' | 'SELL_BURN'>('ALL');
const { data: burnRecords, isLoading: recordsLoading } = useBurnRecords(
recordsPage,
20,
recordsFilter === 'ALL' ? undefined : recordsFilter
);
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 });
};
const formatPrice = (value: string | undefined) => {
if (!value) return '0';
const num = parseFloat(value);
if (isNaN(num)) return '0';
return num.toFixed(18);
};
const handleRefresh = () => {
refetchTrading();
refetchBurn();
refetchMarket();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<PageHeader title="交易系统管理" description="管理交易/销毁系统的运行状态" />
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 交易系统状态卡片 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Activity className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</div>
{tradingLoading ? (
<Skeleton className="h-6 w-16" />
) : !tradingStatus?.initialized ? (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
</Badge>
) : tradingStatus?.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>
{tradingLoading ? (
<Skeleton className="h-32 w-full" />
) : !tradingStatus?.initialized ? (
<div className="text-center py-4 text-muted-foreground">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
<p></p>
<p className="text-sm"> trading-service </p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">{formatNumber(tradingStatus.totalShares, 0)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">{formatNumber(tradingStatus.burnTarget, 0)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">{tradingStatus.burnPeriodMinutes?.toLocaleString()} </p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-lg font-semibold">{formatNumber(tradingStatus.minuteBurnRate, 8)}</p>
</div>
</div>
{tradingStatus.activatedAt && (
<div className="pt-2">
<p className="text-sm text-muted-foreground">
: {new Date(tradingStatus.activatedAt).toLocaleString()} (
{formatDistanceToNow(new Date(tradingStatus.activatedAt), { addSuffix: true, locale: zhCN })})
</p>
</div>
)}
<div className="flex justify-end pt-4 border-t">
{tradingStatus.isActive ? (
<Button
variant="destructive"
onClick={() => deactivateTrading.mutate()}
disabled={deactivateTrading.isPending}
>
<Pause className="h-4 w-4 mr-2" />
{deactivateTrading.isPending ? '停用中...' : '停用交易系统'}
</Button>
) : (
<Button onClick={() => activateTrading.mutate()} disabled={activateTrading.isPending}>
<Play className="h-4 w-4 mr-2" />
{activateTrading.isPending ? '激活中...' : '激活交易系统'}
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* 市场概览和销毁进度 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 市场概览 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
{marketLoading ? (
<Skeleton className="h-40 w-full" />
) : !marketOverview ? (
<div className="text-center py-4 text-muted-foreground">
<p></p>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-green-500" />
<span className="text-sm text-muted-foreground"></span>
</div>
<span className="font-mono text-lg font-semibold">{formatPrice(marketOverview.price)}</span>
</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">{formatNumber(marketOverview.greenPoints, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(marketOverview.circulationPool, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(marketOverview.effectiveDenominator, 0)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(marketOverview.burnMultiplier, 8)}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* 销毁进度 */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent>
{burnLoading ? (
<Skeleton className="h-40 w-full" />
) : !burnStatus ? (
<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(burnStatus.burnProgress, 4)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div
className="bg-orange-500 h-3 rounded-full transition-all"
style={{ width: `${Math.min(parseFloat(burnStatus.burnProgress), 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-orange-500">{formatNumber(burnStatus.totalBurned, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(burnStatus.remainingBurn, 8)}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{burnStatus.remainingMinutes?.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="font-semibold">{formatNumber(burnStatus.minuteBurnRate, 8)}</p>
</div>
</div>
{burnStatus.lastBurnMinute && (
<p className="text-sm text-muted-foreground pt-2 border-t">
: {new Date(burnStatus.lastBurnMinute).toLocaleString()}
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* 销毁记录 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
<Select
value={recordsFilter}
onValueChange={(value) => {
setRecordsFilter(value as 'ALL' | 'MINUTE_BURN' | 'SELL_BURN');
setRecordsPage(1);
}}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="筛选类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="MINUTE_BURN"></SelectItem>
<SelectItem value="SELL_BURN"></SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
{recordsLoading ? (
<div className="p-6">
<Skeleton className="h-64 w-full" />
</div>
) : !burnRecords?.data?.length ? (
<div className="text-center py-8 text-muted-foreground">
<p></p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{burnRecords.data.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-mono text-sm">
{new Date(record.burnMinute).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant={record.sourceType === 'MINUTE_BURN' ? 'secondary' : 'default'}>
{record.sourceType === 'MINUTE_BURN' ? '自动销毁' : '卖出销毁'}
</Badge>
</TableCell>
<TableCell className="font-mono">{formatNumber(record.burnAmount, 8)}</TableCell>
<TableCell className="font-mono">{formatNumber(record.remainingTarget, 0)}</TableCell>
<TableCell className="font-mono text-sm">
{record.sourceAccountSeq || '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
{record.memo || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between px-4 py-3 border-t">
<p className="text-sm text-muted-foreground"> {burnRecords.total} </p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={recordsPage <= 1}
onClick={() => setRecordsPage((p) => p - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={burnRecords.data.length < 20}
onClick={() => setRecordsPage((p) => p + 1)}
>
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -13,12 +13,14 @@ import {
ClipboardList,
ChevronLeft,
ChevronRight,
ArrowLeftRight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
const menuItems = [
{ name: '仪表盘', href: '/dashboard', icon: LayoutDashboard },
{ name: '用户管理', href: '/users', icon: Users },
{ name: '交易管理', href: '/trading', icon: ArrowLeftRight },
{ name: '配置管理', href: '/configs', icon: Settings },
{ name: '系统账户', href: '/system-accounts', icon: Building2 },
{ name: '报表统计', href: '/reports', icon: FileBarChart },

View File

@ -0,0 +1,98 @@
import { apiClient } from '@/lib/api/client';
export interface TradingStatus {
initialized: boolean;
isActive: boolean;
activatedAt: string | null;
totalShares?: string;
burnTarget?: string;
burnPeriodMinutes?: number;
minuteBurnRate?: string;
message: string;
}
export interface BurnStatus {
totalBurned: string;
targetBurn: string;
remainingBurn: string;
burnProgress: string;
minuteBurnRate: string;
remainingMinutes: number;
lastBurnMinute: string | null;
}
export interface BurnRecord {
id: string;
burnMinute: string;
burnAmount: string;
remainingTarget: string;
sourceType: 'MINUTE_BURN' | 'SELL_BURN';
sourceAccountSeq: string | null;
sourceOrderNo: string | null;
memo: string | null;
createdAt: string;
}
export interface BurnRecordsResponse {
data: BurnRecord[];
total: number;
}
export const tradingApi = {
// 获取交易系统状态
getTradingStatus: async (): Promise<TradingStatus> => {
const response = await apiClient.get('/trading/admin/trading/status');
return response.data;
},
// 激活交易系统
activateTrading: async (): Promise<{ success: boolean; message: string; activatedAt?: string }> => {
const response = await apiClient.post('/trading/admin/trading/activate');
return response.data;
},
// 关闭交易系统
deactivateTrading: async (): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post('/trading/admin/trading/deactivate');
return response.data;
},
// 获取销毁状态
getBurnStatus: async (): Promise<BurnStatus> => {
const response = await apiClient.get('/trading/burn/status');
return response.data;
},
// 获取销毁记录
getBurnRecords: async (
page: number = 1,
pageSize: number = 20,
sourceType?: 'MINUTE_BURN' | 'SELL_BURN'
): Promise<BurnRecordsResponse> => {
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (sourceType) {
params.append('sourceType', sourceType);
}
const response = await apiClient.get(`/trading/burn/records?${params.toString()}`);
return response.data;
},
// 获取市场概览
getMarketOverview: async (): Promise<{
price: string;
greenPoints: string;
blackHoleAmount: string;
circulationPool: string;
effectiveDenominator: string;
burnMultiplier: string;
totalShares: string;
burnTarget: string;
burnProgress: string;
}> => {
const response = await apiClient.get('/trading/asset/market');
return response.data.data;
},
};

View File

@ -0,0 +1,78 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tradingApi } from '../api/trading.api';
import { useToast } from '@/lib/hooks/use-toast';
export function useTradingStatus() {
return useQuery({
queryKey: ['trading', 'status'],
queryFn: () => tradingApi.getTradingStatus(),
refetchInterval: 30000,
});
}
export function useActivateTrading() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: () => tradingApi.activateTrading(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['trading'] });
toast({ title: data.message || '交易系统已激活', variant: 'success' as any });
},
onError: (error: any) => {
toast({
title: '激活失败',
description: error?.response?.data?.message || '请稍后重试',
variant: 'destructive',
});
},
});
}
export function useDeactivateTrading() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: () => tradingApi.deactivateTrading(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['trading'] });
toast({ title: data.message || '交易系统已关闭', variant: 'success' as any });
},
onError: (error: any) => {
toast({
title: '关闭失败',
description: error?.response?.data?.message || '请稍后重试',
variant: 'destructive',
});
},
});
}
export function useBurnStatus() {
return useQuery({
queryKey: ['trading', 'burn-status'],
queryFn: () => tradingApi.getBurnStatus(),
refetchInterval: 60000,
});
}
export function useBurnRecords(
page: number = 1,
pageSize: number = 20,
sourceType?: 'MINUTE_BURN' | 'SELL_BURN'
) {
return useQuery({
queryKey: ['trading', 'burn-records', page, pageSize, sourceType],
queryFn: () => tradingApi.getBurnRecords(page, pageSize, sourceType),
});
}
export function useMarketOverview() {
return useQuery({
queryKey: ['trading', 'market-overview'],
queryFn: () => tradingApi.getMarketOverview(),
refetchInterval: 60000,
});
}

View File

@ -0,0 +1,2 @@
export * from './api/trading.api';
export * from './hooks/use-trading';