diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/statistics/CopyTradingStatisticsController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/statistics/CopyTradingStatisticsController.kt index b717eab..5765d98 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/statistics/CopyTradingStatisticsController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/statistics/CopyTradingStatisticsController.kt @@ -200,5 +200,51 @@ class CopyOrderTrackingController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource)) } } + + /** + * 查询按市场分组的订单列表(买入/卖出) + * POST /api/copy-trading/orders/grouped-by-market + */ + @PostMapping("/grouped-by-market") + fun getOrderListGroupedByMarket(@RequestBody request: MarketGroupedOrdersRequest): ResponseEntity> { + return try { + if (request.copyTradingId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource)) + } + + if (request.type.isBlank()) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "订单类型不能为空", messageSource)) + } + + val validTypes = listOf("buy", "sell") + if (!validTypes.contains(request.type.lowercase())) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "订单类型必须是 buy 或 sell", messageSource)) + } + + val result: Result = runBlocking { + when (request.type.lowercase()) { + "buy" -> statisticsService.getBuyOrderListGroupedByMarket(request) + "sell" -> statisticsService.getSellOrderListGroupedByMarket(request) + else -> Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}")) + } + } + + result.fold( + onSuccess = { response: MarketGroupedOrdersResponse -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e: Throwable -> + logger.error("查询按市场分组的订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource)) + } + } + ) + } catch (e: Exception) { + logger.error("查询按市场分组的订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt index 324135d..18d4144 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt @@ -113,6 +113,51 @@ data class OrderTrackingRequest( val buyOrderId: String? = null ) +/** + * 按市场分组的订单查询请求 + */ +data class MarketGroupedOrdersRequest( + val copyTradingId: Long, + val type: String, // buy, sell, matched + val page: Int? = 1, + val limit: Int? = 20 +) + +/** + * 单个市场的订单统计信息 + */ +data class MarketOrderStats( + val count: Long, + val totalAmount: String, // 总金额 + val totalPnl: String?, // 总盈亏(买入订单未实现盈亏,此字段为空) + val fullyMatched: Boolean, // 是否全部成交 + val fullyMatchedCount: Long, // 完全成交的订单数 + val partiallyMatchedCount: Long, // 部分成交的订单数 + val filledCount: Long // 未成交的订单数 +) + +/** + * 单个市场分组的响应数据 + */ +data class MarketOrderGroup( + val marketId: String, + val marketTitle: String?, + val marketSlug: String?, + val marketCategory: String?, + val stats: MarketOrderStats, + val orders: List // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表 +) + +/** + * 按市场分组的订单列表响应 + */ +data class MarketGroupedOrdersResponse( + val list: List, + val total: Long, // 市场总数 + val page: Int, + val limit: Int +) + /** * 统计查询请求 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt index cb44d9a..c0a8277 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyTradingStatisticsService.kt @@ -727,5 +727,215 @@ class CopyTradingStatisticsService( val currentPositionQuantity: String, val totalRealizedPnl: String ) + + /** + * 获取按市场分组的买入订单列表 + */ + fun getBuyOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result { + return try { + // 1. 验证跟单关系 + copyTradingRepository.findById(request.copyTradingId).orElse(null) + ?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}")) + + // 2. 获取所有买入订单 + var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId) + + // 3. 按市场ID分组 + val groups = mutableMapOf>() + orders.forEach { order -> + val marketId = order.marketId + if (!groups.containsKey(marketId)) { + groups[marketId] = mutableListOf() + } + groups[marketId]!!.add(order) + } + + // 4. 转换为分组数据并计算统计信息 + val marketIds = groups.keys.toList() + val markets = marketService.getMarkets(marketIds) + + val list = marketIds.map { marketId -> + val marketOrders = groups[marketId] ?: mutableListOf() + + // 计算统计信息 + val count = marketOrders.size.toLong() + val totalAmount = marketOrders.sumOf { order -> + order.quantity.toSafeBigDecimal().multi(order.price) + } + + // 计算订单状态统计 + val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" } + val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" } + val filledCount = marketOrders.count { it.status == "filled" } + val fullyMatched = fullyMatchedCount == marketOrders.size + + val stats = MarketOrderStats( + count = count, + totalAmount = totalAmount.toString(), + totalPnl = null, // 买入订单没有已实现盈亏 + fullyMatched = fullyMatched, + fullyMatchedCount = fullyMatchedCount.toLong(), + partiallyMatchedCount = partiallyMatchedCount.toLong(), + filledCount = filledCount.toLong() + ) + + // 排序(按创建时间倒序) + marketOrders.sortByDescending { it.createdAt } + + // 转换为 DTO + val orderDtos = marketOrders.map { order -> + val amount = order.quantity.toSafeBigDecimal().multi(order.price) + val market = markets[order.marketId] + BuyOrderInfo( + orderId = order.buyOrderId, + leaderTradeId = order.leaderBuyTradeId, + marketId = order.marketId, + marketTitle = market?.title, + marketSlug = market?.slug, + marketCategory = market?.category, + side = order.side, + quantity = order.quantity.toString(), + price = order.price.toString(), + amount = amount.toString(), + matchedQuantity = order.matchedQuantity.toString(), + remainingQuantity = order.remainingQuantity.toString(), + status = order.status, + createdAt = order.createdAt + ) + } + + MarketOrderGroup( + marketId = marketId, + marketTitle = markets[marketId]?.title, + marketSlug = markets[marketId]?.slug, + marketCategory = markets[marketId]?.category, + stats = stats, + orders = orderDtos as List + ) + }.sortedByDescending { it.stats.count } + + // 5. 分页 + val page = (request.page ?: 1) + val limit = request.limit ?: 20 + val total = list.size.toLong() + + val start = (page - 1) * limit + val end = minOf(start + limit, list.size) + val pagedList = if (start < list.size) list.subList(start, end) else emptyList() + + val response = MarketGroupedOrdersResponse( + list = pagedList, + total = total, + page = page, + limit = limit + ) + + Result.success(response) + } catch (e: Exception) { + logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e) + Result.failure(e) + } + } + + /** + * 获取按市场分组的卖出订单列表 + */ + fun getSellOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result { + return try { + // 1. 验证跟单关系 + copyTradingRepository.findById(request.copyTradingId).orElse(null) + ?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}")) + + // 2. 获取所有卖出记录 + val sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId) + + // 3. 按市场ID分组 + val groups = mutableMapOf>() + sellRecords.forEach { record -> + val marketId = record.marketId + if (!groups.containsKey(marketId)) { + groups[marketId] = mutableListOf() + } + groups[marketId]!!.add(record) + } + + // 4. 转换为分组数据并计算统计信息 + val marketIds = groups.keys.toList() + val markets = marketService.getMarkets(marketIds) + + val list = marketIds.map { marketId -> + val marketRecords = groups[marketId] ?: mutableListOf() + + // 计算统计信息 + val count = marketRecords.size.toLong() + val totalAmount = marketRecords.sumOf { record -> + record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice) + } + val totalPnl = marketRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() } + + val stats = MarketOrderStats( + count = count, + totalAmount = totalAmount.toString(), + totalPnl = totalPnl.toString(), + fullyMatched = true, // 卖出订单都是已成交的 + fullyMatchedCount = count, // 所有订单都是已成交的 + partiallyMatchedCount = 0L, + filledCount = 0L + ) + + // 排序(按创建时间倒序) + marketRecords.sortByDescending { it.createdAt } + + // 转换为 DTO + val orderDtos = marketRecords.map { record -> + val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice) + val market = markets[record.marketId] + SellOrderInfo( + orderId = record.sellOrderId, + leaderTradeId = record.leaderSellTradeId, + marketId = record.marketId, + marketTitle = market?.title, + marketSlug = market?.slug, + marketCategory = market?.category, + side = record.side, + quantity = record.totalMatchedQuantity.toString(), + price = record.sellPrice.toString(), + amount = amount.toString(), + realizedPnl = record.totalRealizedPnl.toString(), + createdAt = record.createdAt + ) + } + + MarketOrderGroup( + marketId = marketId, + marketTitle = markets[marketId]?.title, + marketSlug = markets[marketId]?.slug, + marketCategory = markets[marketId]?.category, + stats = stats, + orders = orderDtos as List + ) + }.sortedByDescending { it.stats.count } + + // 5. 分页 + val page = (request.page ?: 1) + val limit = request.limit ?: 20 + val total = list.size.toLong() + + val start = (page - 1) * limit + val end = minOf(start + limit, list.size) + val pagedList = if (start < list.size) list.subList(start, end) else emptyList() + + val response = MarketGroupedOrdersResponse( + list = pagedList, + total = total, + page = page, + limit = limit + ) + + Result.success(response) + } catch (e: Exception) { + logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e) + Result.failure(e) + } + } } - diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 1d83e94..4efaf19 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1062,6 +1062,7 @@ "totalAmount": "Total Amount", "totalPnl": "Total PnL", "statusBreakdown": "Status", + "markets": "markets", "fetchBuyOrdersFailed": "Failed to fetch buy orders", "fetchSellOrdersFailed": "Failed to fetch sell orders", "fetchMatchedOrdersFailed": "Failed to fetch matched orders", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 522a329..4a11b36 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1060,8 +1060,8 @@ "partiallyMatched": "部分成交", "orderCount": "订单数", "totalAmount": "总金额", - "totalPnl": "总盈亏", "statusBreakdown": "状态", + "markets": "个市场", "fetchBuyOrdersFailed": "获取买入订单列表失败", "fetchSellOrdersFailed": "获取卖出订单列表失败", "fetchMatchedOrdersFailed": "获取匹配关系列表失败", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 7868027..abae376 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1062,6 +1062,7 @@ "totalAmount": "總金額", "totalPnl": "總盈虧", "statusBreakdown": "狀態", + "markets": "個市場", "fetchBuyOrdersFailed": "獲取買入訂單列表失敗", "fetchSellOrdersFailed": "獲取賣出訂單列表失敗", "fetchMatchedOrdersFailed": "獲取匹配關係列表失敗", diff --git a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx index ca87a01..7edd6e3 100644 --- a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState } from 'react' import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message, Switch, Space } from 'antd' import { apiService } from '../../services/api' import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils' import { useMediaQuery } from 'react-responsive' import { useTranslation } from 'react-i18next' -import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types' +import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse, MarketGroupedOrdersRequest, MarketGroupedOrdersResponse, MarketOrderGroup } from '../../types' const { Option } = Select @@ -30,31 +30,51 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal }>({}) const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组 const [expandedMarkets, setExpandedMarkets] = useState>(new Set()) // 展开的市场ID集合 + const [groupedOrders, setGroupedOrders] = useState([]) // 分组数据(从后端获取) + const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量) useEffect(() => { if (copyTradingId && active) { fetchOrders() } - }, [copyTradingId, active, page, limit, filters]) + }, [copyTradingId, active, page, limit, filters, groupByMarket]) const fetchOrders = async () => { if (!copyTradingId) return setLoading(true) try { - const request: OrderTrackingRequest = { - copyTradingId: parseInt(copyTradingId), - type: 'buy', - page, - limit, - ...filters - } + if (groupByMarket) { + // 调用分组接口 + const request: MarketGroupedOrdersRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'buy', + page, + limit + } - const response = await apiService.orderTracking.list(request) - if (response.data.code === 0 && response.data.data) { - const data = response.data.data as OrderTrackingListResponse - setOrders((data.list || []) as BuyOrderInfo[]) - setTotal(data.total || 0) + const response = await apiService.orderTracking.listGroupedByMarket(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as MarketGroupedOrdersResponse + setGroupedOrders(data.list || []) + setGroupedTotal(data.total || 0) + } + } else { + // 调用普通列表接口 + const request: OrderTrackingRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'buy', + page, + limit, + ...filters + } + + const response = await apiService.orderTracking.list(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as OrderTrackingListResponse + setOrders((data.list || []) as BuyOrderInfo[]) + setTotal(data.total || 0) + } } } catch (error: any) { console.error('获取买入订单列表失败:', error) @@ -82,69 +102,6 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal } } - // 分组数据结构 - interface GroupedOrders { - marketId: string - marketTitle?: string - marketSlug?: string - marketCategory?: string - orders: BuyOrderInfo[] - stats: { - count: number - totalAmount: string - fullyMatched: boolean // 是否全部成交 - fullyMatchedCount: number // 完全成交的订单数 - partiallyMatchedCount: number // 部分成交的订单数 - filledCount: number // 未成交的订单数 - } - } - - // 按市场分组订单 - const groupedOrders = useMemo(() => { - if (!groupByMarket) return [] - - const groups = new Map() - - // 按市场ID分组 - orders.forEach(order => { - const key = order.marketId - if (!groups.has(key)) { - groups.set(key, []) - } - groups.get(key)!.push(order) - }) - - // 转换为分组数据并计算统计信息 - return Array.from(groups.entries()).map(([marketId, marketOrders]) => { - // 计算总金额 - const totalAmount = marketOrders.reduce((sum, order) => { - const amount = parseFloat(order.quantity) * parseFloat(order.price) - return sum + amount - }, 0) - - // 统计订单状态 - const fullyMatchedCount = marketOrders.filter(o => o.status === 'fully_matched').length - const partiallyMatchedCount = marketOrders.filter(o => o.status === 'partially_matched').length - const filledCount = marketOrders.filter(o => o.status === 'filled').length - const fullyMatched = fullyMatchedCount === marketOrders.length - - return { - marketId, - marketTitle: marketOrders[0]?.marketTitle, - marketSlug: marketOrders[0]?.marketSlug, - marketCategory: marketOrders[0]?.marketCategory, - orders: marketOrders, - stats: { - count: marketOrders.length, - totalAmount: totalAmount.toString(), - fullyMatched, - fullyMatchedCount, - partiallyMatchedCount, - filledCount - } - } - }).sort((a, b) => b.stats.count - a.stats.count) // 按订单数量降序排序 - }, [orders, groupByMarket]) // 切换市场展开/折叠 const toggleMarket = (marketId: string) => { @@ -333,12 +290,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal } ] - // 初始化展开状态(桌面端默认全部展开) - useEffect(() => { - if (groupByMarket && groupedOrders.length > 0 && expandedMarkets.size === 0 && !isMobile) { - setExpandedMarkets(new Set(groupedOrders.map(g => g.marketId))) - } - }, [groupByMarket, groupedOrders.length, isMobile]) + // 分组默认全部折叠(不自动展开) // 渲染分组视图 const renderGroupedView = () => { @@ -356,6 +308,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal const isExpanded = expandedMarkets.has(group.marketId) const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6) const marketUrl = getPolymarketUrl(group.marketSlug, group.marketCategory, group.marketId) + const orders = group.orders as BuyOrderInfo[] return ( = ({ copyTradingId, active = fal {isMobile ? (
- {group.orders.map((order) => { + {orders.map((order) => { const date = new Date(order.createdAt) const formattedDate = date.toLocaleString('zh-CN', { year: 'numeric', @@ -501,7 +454,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal ) : ( = ({ copyTradingId, active = fal )} + {groupByMarket && ( +
+ {t('common.total') || '共'} {groupedTotal} {t('copyTradingOrders.markets') || '个市场'} +
+ )} + @@ -742,7 +701,37 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal ) : groupByMarket ? ( - renderGroupedView() + <> + {renderGroupedView()} + {groupedOrders.length > 0 && ( +
+ + + {t('common.page') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'} + + +
+ )} + ) : ( renderListView() )} diff --git a/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx index 5c63d6b..46ea76e 100644 --- a/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState } from 'react' import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message, Switch, Space } from 'antd' import { apiService } from '../../services/api' import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils' import { useMediaQuery } from 'react-responsive' import { useTranslation } from 'react-i18next' -import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types' +import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse, MarketGroupedOrdersRequest, MarketGroupedOrdersResponse, MarketOrderGroup } from '../../types' const { Option } = Select @@ -29,31 +29,51 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f }>({}) const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组 const [expandedMarkets, setExpandedMarkets] = useState>(new Set()) // 展开的市场ID集合 + const [groupedOrders, setGroupedOrders] = useState([]) // 分组数据(从后端获取) + const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量) useEffect(() => { if (copyTradingId && active) { fetchOrders() } - }, [copyTradingId, active, page, limit, filters]) + }, [copyTradingId, active, page, limit, filters, groupByMarket]) const fetchOrders = async () => { if (!copyTradingId) return setLoading(true) try { - const request: OrderTrackingRequest = { - copyTradingId: parseInt(copyTradingId), - type: 'sell', - page, - limit, - ...filters - } + if (groupByMarket) { + // 调用分组接口 + const request: MarketGroupedOrdersRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'sell', + page, + limit + } - const response = await apiService.orderTracking.list(request) - if (response.data.code === 0 && response.data.data) { - const data = response.data.data as OrderTrackingListResponse - setOrders((data.list || []) as SellOrderInfo[]) - setTotal(data.total || 0) + const response = await apiService.orderTracking.listGroupedByMarket(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as MarketGroupedOrdersResponse + setGroupedOrders(data.list || []) + setGroupedTotal(data.total || 0) + } + } else { + // 调用普通列表接口 + const request: OrderTrackingRequest = { + copyTradingId: parseInt(copyTradingId), + type: 'sell', + page, + limit, + ...filters + } + + const response = await apiService.orderTracking.list(request) + if (response.data.code === 0 && response.data.data) { + const data = response.data.data as OrderTrackingListResponse + setOrders((data.list || []) as SellOrderInfo[]) + setTotal(data.total || 0) + } } } catch (error: any) { console.error('获取卖出订单列表失败:', error) @@ -77,64 +97,6 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f } } - // 分组数据结构 - interface GroupedOrders { - marketId: string - marketTitle?: string - marketSlug?: string - marketCategory?: string - orders: SellOrderInfo[] - stats: { - count: number - totalAmount: string - totalPnl: string // 总盈亏 - fullyMatched: boolean // 卖出订单都是已成交的,这里始终为 true - } - } - - // 按市场分组订单 - const groupedOrders = useMemo(() => { - if (!groupByMarket) return [] - - const groups = new Map() - - // 按市场ID分组 - orders.forEach(order => { - const key = order.marketId - if (!groups.has(key)) { - groups.set(key, []) - } - groups.get(key)!.push(order) - }) - - // 转换为分组数据并计算统计信息 - return Array.from(groups.entries()).map(([marketId, marketOrders]) => { - // 计算总金额和总盈亏 - const totalAmount = marketOrders.reduce((sum, order) => { - const amount = parseFloat(order.quantity) * parseFloat(order.price) - return sum + amount - }, 0) - - const totalPnl = marketOrders.reduce((sum, order) => { - return sum + parseFloat(order.realizedPnl) - }, 0) - - return { - marketId, - marketTitle: marketOrders[0]?.marketTitle, - marketSlug: marketOrders[0]?.marketSlug, - marketCategory: marketOrders[0]?.marketCategory, - orders: marketOrders, - stats: { - count: marketOrders.length, - totalAmount: totalAmount.toString(), - totalPnl: totalPnl.toString(), - fullyMatched: true // 卖出订单都是已成交的 - } - } - }).sort((a, b) => b.stats.count - a.stats.count) // 按订单数量降序排序 - }, [orders, groupByMarket]) - // 切换市场展开/折叠 const toggleMarket = (marketId: string) => { const newExpanded = new Set(expandedMarkets) @@ -155,12 +117,7 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f } } - // 初始化展开状态(桌面端默认全部展开) - useEffect(() => { - if (groupByMarket && groupedOrders.length > 0 && expandedMarkets.size === 0 && !isMobile) { - setExpandedMarkets(new Set(groupedOrders.map(g => g.marketId))) - } - }, [groupByMarket, groupedOrders.length, isMobile]) + // 分组默认全部折叠(不自动展开) const columns = [ { @@ -335,7 +292,8 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f const isExpanded = expandedMarkets.has(group.marketId) const marketDisplayName = group.marketTitle || group.marketId.slice(0, 8) + '...' + group.marketId.slice(-6) const marketUrl = getPolymarketUrl(group.marketSlug, group.marketCategory, group.marketId) - const pnlColor = getPnlColor(group.stats.totalPnl) + const pnlColor = getPnlColor(group.stats.totalPnl || '0') + const orders = group.orders as SellOrderInfo[] return ( = ({ copyTradingId, active = f
{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count} {t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC - - {t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC - + {group.stats.totalPnl && ( + + {t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC + + )}
= ({ copyTradingId, active = f )} + {groupByMarket && ( +
+ {t('common.total') || '共'} {groupedTotal} {t('copyTradingOrders.markets') || '个市场'} +
+ )} + @@ -707,7 +673,37 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f ) : groupByMarket ? ( - renderGroupedView() + <> + {renderGroupedView()} + {groupedOrders.length > 0 && ( +
+ + + {t('common.page') || '第'} {page} / {Math.ceil(groupedTotal / limit)} {t('common.page') || '页'} + + +
+ )} + ) : ( renderListView() )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 69010cb..7ed9c24 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -472,7 +472,13 @@ export const apiService = { * 查询订单列表(买入/卖出/匹配) */ list: (data: any) => - apiClient.post>('/copy-trading/orders/tracking', data) + apiClient.post>('/copy-trading/orders/tracking', data), + + /** + * 查询按市场分组的订单列表(买入/卖出) + */ + listGroupedByMarket: (data: any) => + apiClient.post>('/copy-trading/orders/grouped-by-market', data) }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a5d6be0..c119ec5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -700,6 +700,51 @@ export interface OrderTrackingRequest { buyOrderId?: string } +/** + * 按市场分组的订单查询请求 + */ +export interface MarketGroupedOrdersRequest { + copyTradingId: number + type: 'buy' | 'sell' + page?: number + limit?: number +} + +/** + * 单个市场的订单统计信息 + */ +export interface MarketOrderStats { + count: number + totalAmount: string // 总金额 + totalPnl?: string // 总盈亏(买入订单未实现盈亏,此字段为空) + fullyMatched: boolean // 是否全部成交 + fullyMatchedCount: number // 完全成交的订单数 + partiallyMatchedCount: number // 部分成交的订单数 + filledCount: number // 未成交的订单数 +} + +/** + * 单个市场分组的响应数据 + */ +export interface MarketOrderGroup { + marketId: string + marketTitle?: string + marketSlug?: string + marketCategory?: string + stats: MarketOrderStats + orders: BuyOrderInfo[] | SellOrderInfo[] // 订单列表 +} + +/** + * 按市场分组的订单列表响应 + */ +export interface MarketGroupedOrdersResponse { + list: MarketOrderGroup[] + total: number // 市场总数 + page: number + limit: number +} + /** * 被过滤订单信息 */ diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index e73116d..c0970c1 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -96,24 +96,27 @@ export const isAutoGeneratedOrderId = (orderId: string | undefined | null): bool /** * 构建 Polymarket 市场 URL - * 对于 spread 市场(sports 分类),跳转到 moneyline 页面 + * 对于 moneyline 市场,跳转到 moneyline 页面 + * 注意:目前无法自动判断市场是否为 moneyline,需要后端提供标识 * @param marketSlug - 市场 slug * @param marketCategory - 市场分类(sports, crypto 等) * @param marketId - 市场ID(作为后备) + * @param isMoneyline - 是否为 moneyline 市场(需要后端提供) * @returns Polymarket URL,如果无法构建则返回 null */ export const getPolymarketUrl = ( marketSlug?: string | null, - marketCategory?: string | null, - marketId?: string | null + _marketCategory?: string | null, // 保留参数以便未来使用 + marketId?: string | null, + isMoneyline?: boolean ): string | null => { // 优先使用 slug if (marketSlug) { - // 如果是 sports 分类,跳转到 moneyline 页面(spread 市场) - if (marketCategory?.toLowerCase() === 'sports') { + // 如果是 moneyline 市场,跳转到 moneyline 页面 + if (isMoneyline === true) { return `https://polymarket.com/event/${marketSlug}/moneyline` } - // 其他分类跳转到普通市场页面 + // 其他市场跳转到普通市场页面 return `https://polymarket.com/event/${marketSlug}` }