From 8018fa51104f2921188bcfbdfa7906174acf7d4c Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 15 Jan 2026 20:37:52 -0800 Subject: [PATCH] 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 --- .../src/api/controllers/admin.controller.ts | 99 ++++- frontend/mining-admin-web/.env.production | 1 + frontend/mining-admin-web/next.config.js | 8 + .../src/app/(dashboard)/trading/page.tsx | 373 ++++++++++++++++++ .../src/components/layout/sidebar.tsx | 2 + .../src/features/trading/api/trading.api.ts | 98 +++++ .../src/features/trading/hooks/use-trading.ts | 78 ++++ .../src/features/trading/index.ts | 2 + 8 files changed, 658 insertions(+), 3 deletions(-) create mode 100644 frontend/mining-admin-web/src/app/(dashboard)/trading/page.tsx create mode 100644 frontend/mining-admin-web/src/features/trading/api/trading.api.ts create mode 100644 frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts create mode 100644 frontend/mining-admin-web/src/features/trading/index.ts diff --git a/backend/services/trading-service/src/api/controllers/admin.controller.ts b/backend/services/trading-service/src/api/controllers/admin.controller.ts index d5821d3a..49108192 100644 --- a/backend/services/trading-service/src/api/controllers/admin.controller.ts +++ b/backend/services/trading-service/src/api/controllers/admin.controller.ts @@ -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: '交易系统已关闭,每分钟销毁已暂停', + }; + } } diff --git a/frontend/mining-admin-web/.env.production b/frontend/mining-admin-web/.env.production index 75f94b61..1be8ff7f 100644 --- a/frontend/mining-admin-web/.env.production +++ b/frontend/mining-admin-web/.env.production @@ -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=挖矿管理后台 diff --git a/frontend/mining-admin-web/next.config.js b/frontend/mining-admin-web/next.config.js index 2dcb9a1b..659554aa 100644 --- a/frontend/mining-admin-web/next.config.js +++ b/frontend/mining-admin-web/next.config.js @@ -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*`, diff --git a/frontend/mining-admin-web/src/app/(dashboard)/trading/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/trading/page.tsx new file mode 100644 index 00000000..2d693d91 --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/trading/page.tsx @@ -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 ( +
+
+ + +
+ + {/* 交易系统状态卡片 */} + + +
+
+ + + 交易系统状态 + + 控制交易和每分钟自动销毁的运行状态 +
+ {tradingLoading ? ( + + ) : !tradingStatus?.initialized ? ( + + + 未初始化 + + ) : tradingStatus?.isActive ? ( + + + 运行中 + + ) : ( + + + 已停用 + + )} +
+
+ + {tradingLoading ? ( + + ) : !tradingStatus?.initialized ? ( +
+ +

交易系统未初始化

+

请确保 trading-service 已正常启动

+
+ ) : ( +
+
+
+

总积分股

+

{formatNumber(tradingStatus.totalShares, 0)}

+
+
+

销毁目标

+

{formatNumber(tradingStatus.burnTarget, 0)}

+
+
+

销毁周期

+

{tradingStatus.burnPeriodMinutes?.toLocaleString()} 分钟

+
+
+

每分钟销毁率

+

{formatNumber(tradingStatus.minuteBurnRate, 8)}

+
+
+ + {tradingStatus.activatedAt && ( +
+

+ 激活时间: {new Date(tradingStatus.activatedAt).toLocaleString()} ( + {formatDistanceToNow(new Date(tradingStatus.activatedAt), { addSuffix: true, locale: zhCN })}) +

+
+ )} + +
+ {tradingStatus.isActive ? ( + + ) : ( + + )} +
+
+ )} +
+
+ + {/* 市场概览和销毁进度 */} +
+ {/* 市场概览 */} + + + + + 市场概览 + + + + {marketLoading ? ( + + ) : !marketOverview ? ( +
+

无法获取市场数据

+
+ ) : ( +
+
+
+ + 当前积分股价格 +
+ {formatPrice(marketOverview.price)} +
+
+
+

绿积分

+

{formatNumber(marketOverview.greenPoints, 8)}

+
+
+

流通池

+

{formatNumber(marketOverview.circulationPool, 8)}

+
+
+

有效分母

+

{formatNumber(marketOverview.effectiveDenominator, 0)}

+
+
+

销毁倍数

+

{formatNumber(marketOverview.burnMultiplier, 8)}

+
+
+
+ )} +
+
+ + {/* 销毁进度 */} + + + + + 销毁进度 + + + + {burnLoading ? ( + + ) : !burnStatus ? ( +
+

无法获取销毁数据

+
+ ) : ( +
+
+
+ 销毁进度 + {formatNumber(burnStatus.burnProgress, 4)}% +
+
+
+
+
+
+
+

已销毁

+

{formatNumber(burnStatus.totalBurned, 8)}

+
+
+

剩余目标

+

{formatNumber(burnStatus.remainingBurn, 8)}

+
+
+

剩余分钟

+

{burnStatus.remainingMinutes?.toLocaleString()}

+
+
+

每分钟销毁

+

{formatNumber(burnStatus.minuteBurnRate, 8)}

+
+
+ {burnStatus.lastBurnMinute && ( +

+ 最后销毁: {new Date(burnStatus.lastBurnMinute).toLocaleString()} +

+ )} +
+ )} + + +
+ + {/* 销毁记录 */} + + +
+ 销毁记录 + +
+
+ + {recordsLoading ? ( +
+ +
+ ) : !burnRecords?.data?.length ? ( +
+

暂无销毁记录

+
+ ) : ( + <> + + + + 时间 + 类型 + 销毁量 + 剩余目标 + 来源账户 + 备注 + + + + {burnRecords.data.map((record) => ( + + + {new Date(record.burnMinute).toLocaleString()} + + + + {record.sourceType === 'MINUTE_BURN' ? '自动销毁' : '卖出销毁'} + + + {formatNumber(record.burnAmount, 8)} + {formatNumber(record.remainingTarget, 0)} + + {record.sourceAccountSeq || '-'} + + + {record.memo || '-'} + + + ))} + +
+
+

共 {burnRecords.total} 条记录

+
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/frontend/mining-admin-web/src/components/layout/sidebar.tsx b/frontend/mining-admin-web/src/components/layout/sidebar.tsx index 754846cc..7cc87965 100644 --- a/frontend/mining-admin-web/src/components/layout/sidebar.tsx +++ b/frontend/mining-admin-web/src/components/layout/sidebar.tsx @@ -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 }, diff --git a/frontend/mining-admin-web/src/features/trading/api/trading.api.ts b/frontend/mining-admin-web/src/features/trading/api/trading.api.ts new file mode 100644 index 00000000..82d5085b --- /dev/null +++ b/frontend/mining-admin-web/src/features/trading/api/trading.api.ts @@ -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 => { + 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 => { + 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 => { + 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; + }, +}; diff --git a/frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts b/frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts new file mode 100644 index 00000000..3de9f6f0 --- /dev/null +++ b/frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts @@ -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, + }); +} diff --git a/frontend/mining-admin-web/src/features/trading/index.ts b/frontend/mining-admin-web/src/features/trading/index.ts new file mode 100644 index 00000000..2d365251 --- /dev/null +++ b/frontend/mining-admin-web/src/features/trading/index.ts @@ -0,0 +1,2 @@ +export * from './api/trading.api'; +export * from './hooks/use-trading';