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 885cab8f..31a15db0 100644 --- a/backend/services/trading-service/src/api/controllers/admin.controller.ts +++ b/backend/services/trading-service/src/api/controllers/admin.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Controller, Get, Post, Body, Query, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsBoolean } from 'class-validator'; import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'; import { TradingConfigRepository } from '../../infrastructure/persistence/repositories/trading-config.repository'; +import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository'; +import { OrderType, OrderStatus, OrderSource } from '../../domain/aggregates/order.aggregate'; import { Public } from '../../shared/guards/jwt-auth.guard'; class SetBuyEnabledDto { @@ -21,6 +23,7 @@ export class AdminController { constructor( private readonly prisma: PrismaService, private readonly tradingConfigRepository: TradingConfigRepository, + private readonly orderRepository: OrderRepository, ) {} @Get('accounts/sync') @@ -184,6 +187,88 @@ export class AdminController { }; } + @Get('orders') + @Public() + @ApiOperation({ summary: '获取全局订单列表' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'type', required: false, enum: ['BUY', 'SELL'] }) + @ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'PARTIAL', 'FILLED', 'CANCELLED'] }) + @ApiQuery({ name: 'source', required: false, enum: ['USER', 'MARKET_MAKER', 'DEX_BOT', 'SYSTEM'] }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'startDate', required: false, type: String }) + @ApiQuery({ name: 'endDate', required: false, type: String }) + async getOrders( + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + @Query('type') type?: string, + @Query('status') status?: string, + @Query('source') source?: string, + @Query('search') search?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const p = page ? Number(page) : 1; + const ps = pageSize ? Number(pageSize) : 20; + + const result = await this.orderRepository.findAllOrders({ + type: type as OrderType | undefined, + status: status as OrderStatus | undefined, + source: source as OrderSource | undefined, + search: search || undefined, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + page: p, + pageSize: ps, + }); + + return { + data: result.data, + total: result.total, + page: p, + pageSize: ps, + totalPages: Math.ceil(result.total / ps), + }; + } + + @Get('trades') + @Public() + @ApiOperation({ summary: '获取全局成交记录' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'source', required: false, enum: ['USER', 'MARKET_MAKER', 'DEX_BOT', 'SYSTEM'] }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'startDate', required: false, type: String }) + @ApiQuery({ name: 'endDate', required: false, type: String }) + async getTrades( + @Query('page') page?: number, + @Query('pageSize') pageSize?: number, + @Query('source') source?: string, + @Query('search') search?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const p = page ? Number(page) : 1; + const ps = pageSize ? Number(pageSize) : 20; + + const result = await this.orderRepository.findAllTrades({ + source: source || undefined, + search: search || undefined, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + page: p, + pageSize: ps, + }); + + return { + data: result.data, + total: result.total, + page: p, + pageSize: ps, + totalPages: Math.ceil(result.total / ps), + }; + } + @Post('trading/depth-enabled') @Public() // TODO: 生产环境应添加管理员权限验证 @HttpCode(HttpStatus.OK) diff --git a/backend/services/trading-service/src/infrastructure/persistence/repositories/order.repository.ts b/backend/services/trading-service/src/infrastructure/persistence/repositories/order.repository.ts index 063343cc..678c9075 100644 --- a/backend/services/trading-service/src/infrastructure/persistence/repositories/order.repository.ts +++ b/backend/services/trading-service/src/infrastructure/persistence/repositories/order.repository.ts @@ -324,6 +324,154 @@ export class OrderRepository { }; } + /** + * 全局订单查询(管理后台用) + */ + async findAllOrders(options?: { + type?: OrderType; + status?: OrderStatus; + source?: OrderSource; + search?: string; + startDate?: Date; + endDate?: Date; + page?: number; + pageSize?: number; + }): Promise<{ data: any[]; total: number }> { + const where: any = {}; + + if (options?.type) where.type = options.type; + if (options?.status) where.status = options.status; + if (options?.source) where.source = options.source; + + if (options?.search) { + where.OR = [ + { accountSequence: { contains: options.search } }, + { orderNo: { contains: options.search } }, + ]; + } + + if (options?.startDate || options?.endDate) { + where.createdAt = {}; + if (options?.startDate) where.createdAt.gte = options.startDate; + if (options?.endDate) where.createdAt.lte = options.endDate; + } + + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 20; + + const [records, total] = await Promise.all([ + this.prisma.order.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.order.count({ where }), + ]); + + return { + data: records.map((o) => ({ + id: o.id, + orderNo: o.orderNo, + accountSequence: o.accountSequence, + type: o.type, + status: o.status, + source: o.source, + sourceLabel: o.sourceLabel, + price: o.price.toString(), + quantity: o.quantity.toString(), + filledQuantity: o.filledQuantity.toString(), + remainingQuantity: o.remainingQuantity.toString(), + averagePrice: o.averagePrice.toString(), + totalAmount: o.totalAmount.toString(), + burnQuantity: o.burnQuantity.toString(), + burnMultiplier: o.burnMultiplier.toString(), + effectiveQuantity: o.effectiveQuantity.toString(), + createdAt: o.createdAt, + completedAt: o.completedAt, + cancelledAt: o.cancelledAt, + })), + total, + }; + } + + /** + * 全局成交记录查询(管理后台用) + */ + async findAllTrades(options?: { + source?: string; + search?: string; + startDate?: Date; + endDate?: Date; + page?: number; + pageSize?: number; + }): Promise<{ data: any[]; total: number }> { + const where: any = {}; + + if (options?.source) { + where.OR = [ + { buyerSource: options.source }, + { sellerSource: options.source }, + ]; + } + + if (options?.search) { + const searchCondition = { + OR: [ + { buyerSequence: { contains: options.search } }, + { sellerSequence: { contains: options.search } }, + { tradeNo: { contains: options.search } }, + ], + }; + if (where.OR) { + where.AND = [{ OR: where.OR }, searchCondition]; + delete where.OR; + } else { + where.OR = searchCondition.OR; + } + } + + if (options?.startDate || options?.endDate) { + where.createdAt = {}; + if (options?.startDate) where.createdAt.gte = options.startDate; + if (options?.endDate) where.createdAt.lte = options.endDate; + } + + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 20; + + const [records, total] = await Promise.all([ + this.prisma.trade.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.trade.count({ where }), + ]); + + return { + data: records.map((t) => ({ + id: t.id, + tradeNo: t.tradeNo, + buyOrderId: t.buyOrderId, + sellOrderId: t.sellOrderId, + buyerSequence: t.buyerSequence, + sellerSequence: t.sellerSequence, + price: t.price.toString(), + quantity: t.quantity.toString(), + originalQuantity: t.originalQuantity.toString(), + burnQuantity: t.burnQuantity.toString(), + amount: t.amount.toString(), + fee: t.fee.toString(), + buyerSource: t.buyerSource, + sellerSource: t.sellerSource, + createdAt: t.createdAt, + })), + total, + }; + } + private toDomain(record: any): OrderAggregate { return OrderAggregate.reconstitute({ id: record.id, diff --git a/frontend/mining-admin-web/src/app/(dashboard)/exchange-records/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/exchange-records/page.tsx new file mode 100644 index 00000000..3d9b81b6 --- /dev/null +++ b/frontend/mining-admin-web/src/app/(dashboard)/exchange-records/page.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { useState } from 'react'; +import { PageHeader } from '@/components/layout/page-header'; +import { useAdminOrders, useAdminTrades } from '@/features/trading/hooks/use-trading'; +import type { AdminOrder, AdminTrade } from '@/features/trading/api/trading.api'; +import { Card, CardContent } 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 { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; +import { formatDecimal } from '@/lib/utils/format'; +import { formatDateTime } from '@/lib/utils/date'; + +const orderStatusLabels: Record = { + PENDING: { label: '待成交', className: 'bg-yellow-100 text-yellow-700' }, + PARTIAL: { label: '部分成交', className: 'bg-blue-100 text-blue-700' }, + FILLED: { label: '已成交', className: 'bg-green-100 text-green-700' }, + CANCELLED: { label: '已取消', className: 'bg-gray-100 text-gray-600' }, +}; + +const sourceLabels: Record = { + USER: { label: '用户', className: 'bg-gray-100 text-gray-700' }, + MARKET_MAKER: { label: '做市商', className: 'bg-purple-100 text-purple-700' }, + DEX_BOT: { label: 'DEX Bot', className: 'bg-blue-100 text-blue-700' }, + SYSTEM: { label: '系统', className: 'bg-gray-100 text-gray-600' }, +}; + +export default function ExchangeRecordsPage() { + const [tab, setTab] = useState<'orders' | 'trades'>('orders'); + + // 订单 tab 状态 + const [ordersPage, setOrdersPage] = useState(1); + const [orderType, setOrderType] = useState('ALL'); + const [orderStatus, setOrderStatus] = useState('ALL'); + const [orderSource, setOrderSource] = useState('ALL'); + + // 成交 tab 状态 + const [tradesPage, setTradesPage] = useState(1); + const [tradeSource, setTradeSource] = useState('ALL'); + + // 共享搜索状态 + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + + const pageSize = 20; + + const { data: ordersData, isLoading: ordersLoading } = useAdminOrders({ + page: ordersPage, + pageSize, + type: orderType === 'ALL' ? undefined : orderType as 'BUY' | 'SELL', + status: orderStatus === 'ALL' ? undefined : orderStatus as any, + source: orderSource === 'ALL' ? undefined : orderSource as any, + search: search || undefined, + }); + + const { data: tradesData, isLoading: tradesLoading } = useAdminTrades({ + page: tradesPage, + pageSize, + source: tradeSource === 'ALL' ? undefined : tradeSource as any, + search: search || undefined, + }); + + const handleSearch = () => { + setSearch(searchInput); + setOrdersPage(1); + setTradesPage(1); + }; + + const handleClearSearch = () => { + setSearchInput(''); + setSearch(''); + setOrdersPage(1); + setTradesPage(1); + }; + + const handleTabChange = (value: string) => { + setTab(value as 'orders' | 'trades'); + }; + + const renderBadge = (text: string, className: string) => ( + + {text} + + ); + + const renderSkeletonRows = (cols: number) => + [...Array(5)].map((_, i) => ( + + {[...Array(cols)].map((_, j) => ( + + ))} + + )); + + const renderPagination = ( + data: { total: number; totalPages: number } | undefined, + page: number, + setPage: (p: number) => void, + ) => { + if (!data || data.totalPages <= 1) return null; + return ( +
+

+ 共 {data.total} 条,第 {page} / {data.totalPages} 页 +

+
+ + +
+
+ ); + }; + + return ( +
+ + + {/* 搜索栏 */} + + +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="max-w-md" + /> + + {search && ( + + )} +
+
+
+ + {/* Tabs */} + + + 订单记录 + 成交明细 + + + {/* 订单记录 Tab */} + + {/* 筛选栏 */} + + +
+ + + + + +
+
+
+ + {/* 订单表格 */} + + + + + + 订单号 + 账号 + 类型 + 状态 + 来源 + 价格 + 数量 + 已成交 + 金额 + 时间 + + + + {ordersLoading ? renderSkeletonRows(10) : !ordersData?.data?.length ? ( + + + 暂无订单记录 + + + ) : ( + ordersData.data.map((order: AdminOrder) => { + const status = orderStatusLabels[order.status] || { label: order.status, className: '' }; + const source = sourceLabels[order.source] || { label: order.source, className: '' }; + return ( + + + {order.orderNo} + + {order.accountSequence} + + {renderBadge( + order.type === 'BUY' ? '买入' : '卖出', + order.type === 'BUY' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700', + )} + + {renderBadge(status.label, status.className)} + {renderBadge(source.label, source.className)} + {formatDecimal(order.price, 8)} + {formatDecimal(order.quantity, 2)} + {formatDecimal(order.filledQuantity, 2)} + {formatDecimal(order.totalAmount, 2)} + {formatDateTime(order.createdAt)} + + ); + }) + )} + +
+ {renderPagination(ordersData, ordersPage, setOrdersPage)} +
+
+
+ + {/* 成交明细 Tab */} + + {/* 筛选栏 */} + + +
+ +
+
+
+ + {/* 成交表格 */} + + + + + + 成交号 + 买方 + 卖方 + 价格 + 有效数量 + 原始数量 + 销毁量 + 成交额 + 手续费 + 时间 + + + + {tradesLoading ? renderSkeletonRows(10) : !tradesData?.data?.length ? ( + + + 暂无成交记录 + + + ) : ( + tradesData.data.map((trade: AdminTrade) => ( + + + {trade.tradeNo} + + {trade.buyerSequence} + {trade.sellerSequence} + {formatDecimal(trade.price, 8)} + {formatDecimal(trade.quantity, 2)} + {formatDecimal(trade.originalQuantity, 2)} + {formatDecimal(trade.burnQuantity, 2)} + {formatDecimal(trade.amount, 2)} + {formatDecimal(trade.fee, 2)} + {formatDateTime(trade.createdAt)} + + )) + )} + +
+ {renderPagination(tradesData, tradesPage, setTradesPage)} +
+
+
+
+
+ ); +} diff --git a/frontend/mining-admin-web/src/components/layout/sidebar.tsx b/frontend/mining-admin-web/src/components/layout/sidebar.tsx index e3432f51..a089b4bc 100644 --- a/frontend/mining-admin-web/src/components/layout/sidebar.tsx +++ b/frontend/mining-admin-web/src/components/layout/sidebar.tsx @@ -20,6 +20,7 @@ import { FileSpreadsheet, SendHorizontal, HardDrive, + Repeat, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -27,6 +28,7 @@ const menuItems = [ { name: '仪表盘', href: '/dashboard', icon: LayoutDashboard }, { name: '用户管理', href: '/users', icon: Users }, { name: '交易管理', href: '/trading', icon: ArrowLeftRight }, + { name: '兑换记录', href: '/exchange-records', icon: Repeat }, { name: 'P2P划转', href: '/p2p-transfers', icon: SendHorizontal }, { name: '做市商管理', href: '/market-maker', icon: Bot }, { name: 'C2C Bot', href: '/c2c-bot', icon: Zap }, 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 index 022a4486..09dafa80 100644 --- a/frontend/mining-admin-web/src/features/trading/api/trading.api.ts +++ b/frontend/mining-admin-web/src/features/trading/api/trading.api.ts @@ -76,6 +76,76 @@ export interface BurnRecordsResponse { total: number; } +// ==================== Admin 全局兑换记录类型 ==================== + +export interface AdminOrder { + id: string; + orderNo: string; + accountSequence: string; + type: 'BUY' | 'SELL'; + status: 'PENDING' | 'PARTIAL' | 'FILLED' | 'CANCELLED'; + source: 'USER' | 'MARKET_MAKER' | 'DEX_BOT' | 'SYSTEM'; + sourceLabel: string | null; + price: string; + quantity: string; + filledQuantity: string; + remainingQuantity: string; + averagePrice: string; + totalAmount: string; + burnQuantity: string; + burnMultiplier: string; + effectiveQuantity: string; + createdAt: string; + completedAt: string | null; + cancelledAt: string | null; +} + +export interface AdminTrade { + id: string; + tradeNo: string; + buyOrderId: string; + sellOrderId: string; + buyerSequence: string; + sellerSequence: string; + price: string; + quantity: string; + originalQuantity: string; + burnQuantity: string; + amount: string; + fee: string; + buyerSource: string; + sellerSource: string; + createdAt: string; +} + +export interface AdminPaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface AdminOrdersParams { + page?: number; + pageSize?: number; + type?: 'BUY' | 'SELL'; + status?: 'PENDING' | 'PARTIAL' | 'FILLED' | 'CANCELLED'; + source?: 'USER' | 'MARKET_MAKER' | 'DEX_BOT' | 'SYSTEM'; + search?: string; + startDate?: string; + endDate?: string; +} + +export interface AdminTradesParams { + page?: number; + pageSize?: number; + source?: 'USER' | 'MARKET_MAKER' | 'DEX_BOT' | 'SYSTEM'; + search?: string; + startDate?: string; + endDate?: string; +} + export const tradingApi = { // 获取交易系统状态 getTradingStatus: async (): Promise => { @@ -133,6 +203,18 @@ export const tradingApi = { return response.data.data || response.data; }, + // 获取全局订单列表 (admin) + getAdminOrders: async (params: AdminOrdersParams): Promise> => { + const response = await tradingClient.get('/admin/orders', { params }); + return response.data.data || response.data; + }, + + // 获取全局成交记录 (admin) + getAdminTrades: async (params: AdminTradesParams): Promise> => { + const response = await tradingClient.get('/admin/trades', { params }); + return response.data.data || response.data; + }, + // 获取市场概览 getMarketOverview: async (): Promise<{ price: string; 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 index c98bf878..1d37ec48 100644 --- a/frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts +++ b/frontend/mining-admin-web/src/features/trading/hooks/use-trading.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { tradingApi } from '../api/trading.api'; +import type { AdminOrdersParams, AdminTradesParams } from '../api/trading.api'; import { useToast } from '@/lib/hooks/use-toast'; export function useTradingStatus() { @@ -97,6 +98,20 @@ export function useBurnRecords( }); } +export function useAdminOrders(params: AdminOrdersParams) { + return useQuery({ + queryKey: ['trading', 'admin-orders', params], + queryFn: () => tradingApi.getAdminOrders(params), + }); +} + +export function useAdminTrades(params: AdminTradesParams) { + return useQuery({ + queryKey: ['trading', 'admin-trades', params], + queryFn: () => tradingApi.getAdminTrades(params), + }); +} + export function useMarketOverview() { return useQuery({ queryKey: ['trading', 'market-overview'],