feat: 优化前端UI和功能

- 修复仓位轮询检查逻辑,增加2分钟延迟判断
- 统一订单页面为Tab切换模式,使用Modal展示
- 优化移动端统计UI,改为两列线性布局
- 简化过滤类型标签,移除括号说明
- 账户管理列表优化:钱包地址显示前6后4,移除API凭证和活跃订单列
- 修复复制按钮功能,添加降级方案和Toast提示
- 修复菜单层级问题,订单下拉直接显示已成交和已过滤订单
- 添加编辑功能Modal化
- 完善多语言支持
This commit is contained in:
WrBug
2025-12-09 06:23:51 +08:00
parent cdadce467e
commit cc41df2f24
16 changed files with 2711 additions and 139 deletions
+133
View File
@@ -0,0 +1,133 @@
# 🎉 v1.0.1 版本发布公告
## 📅 发布日期
2025年12月(具体日期待定)
## 🚀 主要更新
### 性能优化
#### 📊 订单簿请求优化
- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
- 订单簿只请求一次,在过滤检查中复用,提高性能
#### 💰 卖出价格计算优化
- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
- 卖出价格固定按 90% 计算,不再使用价格容忍度配置
- 提高卖出订单的成交率
#### ⚙️ 价格容忍度默认值
- 如果价格容忍度配置为 0,自动使用默认值 5%
- 确保买入订单能够正常应用价格调整,提高成交率
### 代码优化
#### 🎯 使用枚举优化过滤逻辑
- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
- 使用类型安全的枚举替代字符串判断,提高代码可维护性
- 移除无用字段 `isBuyOrder` 参数
- 优化 `checkFilters` 方法返回值,使用数据类封装结果
#### 📝 代码结构优化
- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
- 优化卖出价格计算逻辑,使用 `runCatching``?.let` 简化代码
- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
### 功能改进
#### 🗑️ 移除最小订单簿深度功能
- 移除 `minOrderbookDepth`(最小订单簿深度)配置项
- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
- 更新前端界面,移除相关配置项和提示文案
#### 📈 优化最小订单深度逻辑
- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
- 不再区分买卖方向,提供更全面的市场流动性评估
#### 🎯 市场结算判断优化
- 添加市场已关闭时的结算判断逻辑
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
### Bug 修复
- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题
- ✅ 修复过滤检查中订单簿可能重复请求的问题
- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`
## 📦 如何更新
### Docker 部署
```bash
# 拉取最新镜像
docker pull wrbug/polyhermes:v1.0.1
# 或使用 latest 标签
docker pull wrbug/polyhermes:latest
# 重启容器
docker-compose down
docker-compose up -d
```
### 源码部署
```bash
# 拉取最新代码
git fetch origin
git checkout v1.0.1
# 重新构建
cd backend
./gradlew bootJar
# 重启服务
# 根据您的部署方式重启服务
```
## 📚 文档更新
- 更新前端多语言文案,优化提示信息
- 移除最小订单簿深度相关的文档说明
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.0.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@quant_tr
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
+55
View File
@@ -0,0 +1,55 @@
# v1.0.1
## 🚀 性能优化
### 📊 订单簿请求优化
* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
* 订单簿只请求一次,在过滤检查中复用,提高性能
### 💰 卖出价格计算优化
* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
* 卖出价格固定按 90% 计算,不再使用价格容忍度配置
* 提高卖出订单的成交率
### ⚙️ 价格容忍度默认值
* 如果价格容忍度配置为 0,自动使用默认值 5%
* 确保买入订单能够正常应用价格调整,提高成交率
## 🔧 代码优化
### 🎯 使用枚举优化过滤逻辑
* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
* 使用类型安全的枚举替代字符串判断,提高代码可维护性
* 移除无用字段 `isBuyOrder` 参数
* 优化 `checkFilters` 方法返回值,使用数据类封装结果
### 📝 代码结构优化
* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
* 优化卖出价格计算逻辑,使用 `runCatching``?.let` 简化代码
* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
## 🗑️ 功能移除
### 移除最小订单簿深度功能
* 移除 `minOrderbookDepth`(最小订单簿深度)配置项
* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
* 更新前端界面,移除相关配置项和提示文案
### 优化最小订单深度逻辑
* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
* 不再区分买卖方向,提供更全面的市场流动性评估
## 🐛 Bug 修复
* 修复盈亏和持仓价值计算逻辑未实现的问题
* 修复过滤检查中订单簿可能重复请求的问题
## 📚 文档更新
* 更新前端多语言文案,优化提示信息
* 移除最小订单簿深度相关的文档说明
**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1
@@ -192,7 +192,7 @@ class PositionCheckService(
/**
* 逻辑1:处理待赎回仓位
* 按照以下逻辑处理:
https://clob.polymarket.com * 按照以下逻辑处理:
* 1. 无待赎回仓位:跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
@@ -392,11 +392,30 @@ class PositionCheckService(
}
} else {
// 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
// 计算逻辑:
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
val positionQuantity = position.quantity.toSafeBigDecimal()
// 计算总订单数量
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
sum.add(order.remainingQuantity.toSafeBigDecimal())
}
// 计算已成交数量
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
if (soldQuantity <= BigDecimal.ZERO) {
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
continue
}
// 如果已成交数量 > 0,按FIFO顺序匹配订单
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice,
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
}
}
@@ -629,12 +648,22 @@ class PositionCheckService(
/**
* 按 FIFO 顺序更新订单状态为已卖出
* 仓位数量小于订单数量总和时,按订单下单顺序更新
* 同时创建卖出记录和匹配明细,用于统计
* @param orders 订单列表(已按创建时间排序,FIFO)
* @param soldQuantity 已成交数量(总订单数量 - 仓位数量)
* @param sellPrice 卖出价格
* @param copyTradingId 跟单配置ID
* @param marketId 市场ID
* @param outcomeIndex 结果索引
*
* 逻辑说明:
* 1. 按订单创建时间顺序(FIFO)处理
* 2. 如果订单剩余数量 <= 已成交数量,订单完全成交
* 3. 如果订单剩余数量 > 已成交数量,订单部分成交
* 4. 同时创建卖出记录和匹配明细,用于统计
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
availableQuantity: BigDecimal,
soldQuantity: BigDecimal,
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
@@ -646,7 +675,7 @@ class PositionCheckService(
try {
// 订单已经按 createdAt ASC 排序(FIFO
var remaining = availableQuantity
var remaining = soldQuantity
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
+1
View File
@@ -252,6 +252,7 @@ function App() {
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
+69 -1
View File
@@ -868,10 +868,11 @@
"totalPnl": "Total P&L",
"statistics": "Statistics",
"orders": "Orders",
"matchedOrders": "Matched Orders",
"ordersButton": "Orders",
"viewStatistics": "View Statistics",
"buyOrders": "Buy Orders",
"sellOrders": "Sell Orders",
"matchedOrders": "Matched Orders",
"filteredOrders": "Filtered Orders",
"filterWallet": "Filter Wallet",
"filterTemplate": "Filter Template",
@@ -966,5 +967,72 @@
"collapse": "Collapse",
"refresh": "Refresh",
"list": "List"
},
"copyTradingOrders": {
"title": "Order List",
"buyOrders": "Buy Orders",
"sellOrders": "Sell Orders",
"matchedOrders": "Matched Orders",
"filteredOrders": "Filtered Orders",
"statistics": "Copy Trading Statistics",
"orderId": "Order ID",
"leaderTradeId": "Leader Trade ID",
"market": "Market",
"marketId": "Market ID",
"side": "Side",
"buyQuantity": "Buy Quantity",
"buyPrice": "Buy Price",
"buyAmount": "Buy Amount",
"sellQuantity": "Sell Quantity",
"sellPrice": "Sell Price",
"sellAmount": "Sell Amount",
"matchedQuantity": "Matched",
"remainingQuantity": "Remaining",
"sellStatus": "Sell Status",
"status": "Status",
"statusFilled": "Filled",
"statusPartiallySold": "Partially Sold",
"statusFullySold": "Fully Sold",
"realizedPnl": "Realized PnL",
"createdAt": "Created At",
"matchedAt": "Matched At",
"sellOrderId": "Sell Order ID",
"buyOrderId": "Buy Order ID",
"priceInfo": "Price Info",
"buy": "Buy",
"sell": "Sell",
"buyInfo": "Buy Info",
"sellInfo": "Sell Info",
"matchInfo": "Match Info",
"matched": "Matched",
"remaining": "Remaining",
"quantity": "Quantity",
"price": "Price",
"amount": "Amount",
"filterMarketId": "Filter Market ID",
"filterSide": "Filter Side",
"filterStatus": "Filter Status",
"filterSellOrderId": "Filter Sell Order ID",
"filterBuyOrderId": "Filter Buy Order ID",
"noBuyOrders": "No buy orders",
"noSellOrders": "No sell orders",
"noMatchedOrders": "No matched orders",
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
"fetchSellOrdersFailed": "Failed to fetch sell orders",
"fetchMatchedOrdersFailed": "Failed to fetch matched orders",
"fetchStatisticsFailed": "Failed to fetch statistics",
"noStatistics": "No statistics available",
"totalBuyOrders": "Total Buy Orders",
"totalSellOrders": "Total Sell Orders",
"totalMatchedOrders": "Total Matched Orders",
"totalBuyAmount": "Total Buy Amount",
"totalSellAmount": "Total Sell Amount",
"totalPnl": "Total PnL",
"totalRealizedPnl": "Total Realized PnL",
"totalUnrealizedPnl": "Total Unrealized PnL",
"winRate": "Win Rate",
"averagePnl": "Average PnL",
"maxPnl": "Max PnL",
"minPnl": "Min PnL"
}
}
+69 -1
View File
@@ -785,10 +785,11 @@
"totalPnl": "总盈亏",
"statistics": "统计",
"orders": "订单",
"matchedOrders": "已成交订单",
"ordersButton": "订单",
"viewStatistics": "查看统计",
"buyOrders": "买入订单",
"sellOrders": "卖出订单",
"matchedOrders": "匹配关系",
"filteredOrders": "已过滤订单",
"filterWallet": "筛选钱包",
"filterTemplate": "筛选模板",
@@ -883,5 +884,72 @@
"collapse": "收起",
"refresh": "刷新",
"list": "列表"
},
"copyTradingOrders": {
"title": "订单列表",
"buyOrders": "买入订单",
"sellOrders": "卖出订单",
"matchedOrders": "匹配关系",
"filteredOrders": "已过滤订单",
"statistics": "跟单关系统计",
"orderId": "订单ID",
"leaderTradeId": "Leader 交易ID",
"market": "市场",
"marketId": "市场ID",
"side": "方向",
"buyQuantity": "买入数量",
"buyPrice": "买入价格",
"buyAmount": "买入金额",
"sellQuantity": "卖出数量",
"sellPrice": "卖出价格",
"sellAmount": "卖出金额",
"matchedQuantity": "已匹配",
"remainingQuantity": "剩余",
"sellStatus": "卖出状态",
"status": "状态",
"statusFilled": "已完成",
"statusPartiallySold": "部分成交",
"statusFullySold": "全部成交",
"realizedPnl": "已实现盈亏",
"createdAt": "创建时间",
"matchedAt": "匹配时间",
"sellOrderId": "卖出订单ID",
"buyOrderId": "买入订单ID",
"priceInfo": "价格信息",
"buy": "买入",
"sell": "卖出",
"buyInfo": "买入信息",
"sellInfo": "卖出信息",
"matchInfo": "匹配信息",
"matched": "已匹配",
"remaining": "剩余",
"quantity": "数量",
"price": "价格",
"amount": "金额",
"filterMarketId": "筛选市场ID",
"filterSide": "筛选方向",
"filterStatus": "筛选状态",
"filterSellOrderId": "筛选卖出订单ID",
"filterBuyOrderId": "筛选买入订单ID",
"noBuyOrders": "暂无买入订单",
"noSellOrders": "暂无卖出订单",
"noMatchedOrders": "暂无匹配关系",
"fetchBuyOrdersFailed": "获取买入订单列表失败",
"fetchSellOrdersFailed": "获取卖出订单列表失败",
"fetchMatchedOrdersFailed": "获取匹配关系列表失败",
"fetchStatisticsFailed": "获取统计信息失败",
"noStatistics": "暂无统计数据",
"totalBuyOrders": "总买入订单数",
"totalSellOrders": "总卖出订单数",
"totalMatchedOrders": "总匹配订单数",
"totalBuyAmount": "总买入金额",
"totalSellAmount": "总卖出金额",
"totalPnl": "总盈亏",
"totalRealizedPnl": "总已实现盈亏",
"totalUnrealizedPnl": "总未实现盈亏",
"winRate": "胜率",
"averagePnl": "平均盈亏",
"maxPnl": "最大盈亏",
"minPnl": "最小盈亏"
}
}
+69 -1
View File
@@ -868,10 +868,11 @@
"totalPnl": "總盈虧",
"statistics": "統計",
"orders": "訂單",
"matchedOrders": "已成交訂單",
"ordersButton": "訂單",
"viewStatistics": "查看統計",
"buyOrders": "買入訂單",
"sellOrders": "賣出訂單",
"matchedOrders": "匹配關係",
"filteredOrders": "被過濾訂單",
"filterWallet": "篩選錢包",
"filterTemplate": "篩選模板",
@@ -966,5 +967,72 @@
"collapse": "收起",
"refresh": "刷新",
"list": "列表"
},
"copyTradingOrders": {
"title": "訂單列表",
"buyOrders": "買入訂單",
"sellOrders": "賣出訂單",
"matchedOrders": "匹配關係",
"filteredOrders": "已過濾訂單",
"statistics": "跟單關系統計",
"orderId": "訂單ID",
"leaderTradeId": "Leader 交易ID",
"market": "市場",
"marketId": "市場ID",
"side": "方向",
"buyQuantity": "買入數量",
"buyPrice": "買入價格",
"buyAmount": "買入金額",
"sellQuantity": "賣出數量",
"sellPrice": "賣出價格",
"sellAmount": "賣出金額",
"matchedQuantity": "已匹配",
"remainingQuantity": "剩餘",
"sellStatus": "賣出狀態",
"status": "狀態",
"statusFilled": "已完成",
"statusPartiallySold": "部分成交",
"statusFullySold": "全部成交",
"realizedPnl": "已實現盈虧",
"createdAt": "創建時間",
"matchedAt": "匹配時間",
"sellOrderId": "賣出訂單ID",
"buyOrderId": "買入訂單ID",
"priceInfo": "價格信息",
"buy": "買入",
"sell": "賣出",
"buyInfo": "買入信息",
"sellInfo": "賣出信息",
"matchInfo": "匹配信息",
"matched": "已匹配",
"remaining": "剩餘",
"quantity": "數量",
"price": "價格",
"amount": "金額",
"filterMarketId": "篩選市場ID",
"filterSide": "篩選方向",
"filterStatus": "篩選狀態",
"filterSellOrderId": "篩選賣出訂單ID",
"filterBuyOrderId": "篩選買入訂單ID",
"noBuyOrders": "暫無買入訂單",
"noSellOrders": "暫無賣出訂單",
"noMatchedOrders": "暫無匹配關係",
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
"fetchMatchedOrdersFailed": "獲取匹配關係列表失敗",
"fetchStatisticsFailed": "獲取統計信息失敗",
"noStatistics": "暫無統計數據",
"totalBuyOrders": "總買入訂單數",
"totalSellOrders": "總賣出訂單數",
"totalMatchedOrders": "總匹配訂單數",
"totalBuyAmount": "總買入金額",
"totalSellAmount": "總賣出金額",
"totalPnl": "總盈虧",
"totalRealizedPnl": "總已實現盈虧",
"totalUnrealizedPnl": "總未實現盈虧",
"winRate": "勝率",
"averagePnl": "平均盈虧",
"maxPnl": "最大盈虧",
"minPnl": "最小盈虧"
}
}
+99 -76
View File
@@ -74,11 +74,54 @@ const AccountList: React.FC = () => {
}
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
message.success(t('accountList.copySuccess'))
}).catch(() => {
message.error(t('accountList.copyFailed'))
})
if (!text) {
message.warning(t('accountList.copyFailed') || '复制失败:地址为空')
return
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
message.success({
content: t('accountList.copySuccess') || '已复制到剪贴板',
duration: 2
})
}).catch((err) => {
console.error('复制失败:', err)
// 降级方案:使用传统方法
fallbackCopyTextToClipboard(text)
})
} else {
// 降级方案:使用传统方法
fallbackCopyTextToClipboard(text)
}
}
const fallbackCopyTextToClipboard = (text: string) => {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
message.success({
content: t('accountList.copySuccess') || '已复制到剪贴板',
duration: 2
})
} else {
message.error(t('accountList.copyFailed') || '复制失败')
}
} catch (err) {
console.error('复制失败:', err)
message.error(t('accountList.copyFailed') || '复制失败')
} finally {
document.body.removeChild(textArea)
}
}
const handleShowDetail = async (account: Account) => {
@@ -210,46 +253,45 @@ const AccountList: React.FC = () => {
title: t('accountList.walletAddress'),
dataIndex: 'walletAddress',
key: 'walletAddress',
render: (text: string) => (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{text}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(text)}
title={t('accountList.walletAddress')}
/>
</Space>
)
render: (text: string) => {
const formatted = text ? `${text.slice(0, 6)}...${text.slice(-4)}` : '-'
return (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(text)
}}
title={t('accountList.walletAddress')}
/>
</Space>
)
}
},
{
title: t('accountList.proxyAddress'),
dataIndex: 'proxyAddress',
key: 'proxyAddress',
render: (address: string) => (
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{address}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(address)}
title={t('accountList.proxyAddress')}
/>
</Space>
)
},
{
title: t('accountList.apiCredentials'),
key: 'apiCredentials',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
render: (address: string) => {
const formatted = address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'
return (
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
</Tag>
<Space>
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>{formatted}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation()
handleCopy(address)
}}
title={t('accountList.proxyAddress')}
/>
</Space>
)
}
},
@@ -266,17 +308,6 @@ const AccountList: React.FC = () => {
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-'
}
},
{
title: t('accountList.activeOrders'),
dataIndex: 'activeOrders',
key: 'activeOrders',
render: (_: any, record: Account) => {
if (record.activeOrders !== undefined && record.activeOrders !== null) {
return <Tag color={record.activeOrders > 0 ? 'orange' : 'default'}>{record.activeOrders}</Tag>
}
return <span style={{ color: '#999' }}>-</span>
}
},
{
title: t('accountList.action'),
key: 'action',
@@ -323,9 +354,6 @@ const AccountList: React.FC = () => {
title: t('accountList.accountName'),
key: 'info',
render: (_: any, record: Account) => {
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
return (
<div style={{ padding: '8px 0' }}>
<div style={{
@@ -344,31 +372,32 @@ const AccountList: React.FC = () => {
lineHeight: '1.4'
}}>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress}
<strong>{t('accountList.walletAddress')}:</strong> {record.walletAddress ? `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(record.walletAddress)}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.walletAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress}
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(record.proxyAddress)}
onClick={(e) => {
e.stopPropagation()
handleCopy(record.proxyAddress)
}}
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
</div>
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
{allConfigured ? t('accountList.fullConfig') : partialConfigured ? t('accountList.partialConfig') : t('accountList.notConfigured')}
</Tag>
</div>
<div style={{
fontSize: '14px',
fontWeight: '500',
@@ -391,18 +420,6 @@ const AccountList: React.FC = () => {
{t('accountList.available')}: {formatUSDC(balanceMap[record.id].available)} USDC | {t('accountList.position')}: {formatUSDC(balanceMap[record.id].position)} USDC
</div>
)}
{(record.activeOrders !== undefined && record.activeOrders !== null) && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{t('accountList.activeOrders')}: <Tag color={record.activeOrders > 0 ? 'orange' : 'default'} style={{ margin: 0 }}>{record.activeOrders}</Tag>
</div>
)}
</div>
)
}
@@ -598,7 +615,10 @@ const AccountList: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.walletAddress || '')}
onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.walletAddress || '')
}}
title={t('accountList.walletAddress')}
/>
</Space>
@@ -618,7 +638,10 @@ const AccountList: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(detailAccount.proxyAddress || '')}
onClick={(e) => {
e.stopPropagation()
handleCopy(detailAccount.proxyAddress || '')
}}
title={t('accountList.proxyAddress')}
/>
</Space>
+77 -52
View File
@@ -9,6 +9,10 @@ import { useAccountStore } from '../store/accountStore'
import type { CopyTrading, Leader, CopyTradingStatistics } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
import CopyTradingOrdersModal from './CopyTradingOrders/index'
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
const { Option } = Select
@@ -28,6 +32,17 @@ const CopyTradingList: React.FC = () => {
enabled?: boolean
}>({})
// Modal 状态
const [ordersModalOpen, setOrdersModalOpen] = useState(false)
const [ordersModalCopyTradingId, setOrdersModalCopyTradingId] = useState<string>('')
const [ordersModalTab, setOrdersModalTab] = useState<'buy' | 'sell' | 'matched'>('buy')
const [statisticsModalOpen, setStatisticsModalOpen] = useState(false)
const [statisticsModalCopyTradingId, setStatisticsModalCopyTradingId] = useState<string>('')
const [filteredOrdersModalOpen, setFilteredOrdersModalOpen] = useState(false)
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
const [editModalOpen, setEditModalOpen] = useState(false)
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
useEffect(() => {
fetchAccounts()
fetchLeaders()
@@ -265,47 +280,24 @@ const CopyTradingList: React.FC = () => {
fixed: 'right' as const,
render: (_: any, record: CopyTrading) => {
const menuItems: MenuProps['items'] = [
{
key: 'buyOrders',
label: t('copyTradingList.buyOrders') || '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: t('copyTradingList.sellOrders') || '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系',
label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
},
{
type: 'divider'
},
{
key: 'delete',
label: (
<Popconfirm
title={t('copyTradingList.deleteConfirm') || '确定要删除这个跟单关系吗?'}
onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'}
cancelText={t('common.cancel') || '取消'}
onCancel={(e) => e?.stopPropagation()}
>
<span style={{ color: '#ff4d4f' }}>{t('common.delete') || '删除'}</span>
</Popconfirm>
),
danger: true
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
@@ -317,7 +309,10 @@ const CopyTradingList: React.FC = () => {
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/copy-trading/edit/${record.id}`)}
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
>
{t('common.edit') || '编辑'}
</Button>
@@ -325,7 +320,10 @@ const CopyTradingList: React.FC = () => {
type="link"
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
>
{t('copyTradingList.statistics') || '统计'}
</Button>
@@ -554,7 +552,10 @@ const CopyTradingList: React.FC = () => {
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/copy-trading/edit/${record.id}`)}
onClick={() => {
setEditModalCopyTradingId(record.id.toString())
setEditModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
@@ -562,7 +563,10 @@ const CopyTradingList: React.FC = () => {
<Button
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
onClick={() => {
setStatisticsModalCopyTradingId(record.id.toString())
setStatisticsModalOpen(true)
}}
style={{ flex: 1, minWidth: '80px' }}
>
{t('copyTradingList.statistics') || '统计'}
@@ -570,29 +574,24 @@ const CopyTradingList: React.FC = () => {
<Dropdown
menu={{
items: [
{
key: 'buyOrders',
label: t('copyTradingList.buyOrders') || '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: t('copyTradingList.sellOrders') || '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: t('copyTradingList.matchedOrders') || '匹配关系',
label: t('copyTradingList.matchedOrders') || '已成交订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
onClick: () => {
setOrdersModalCopyTradingId(record.id.toString())
setOrdersModalTab('buy')
setOrdersModalOpen(true)
}
},
{
key: 'filteredOrders',
label: t('copyTradingList.filteredOrders') || '已过滤订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`)
onClick: () => {
setFilteredOrdersModalCopyTradingId(record.id.toString())
setFilteredOrdersModalOpen(true)
}
}
]
}}
@@ -643,6 +642,32 @@ const CopyTradingList: React.FC = () => {
/>
)}
</Card>
{/* Modal 组件 */}
<CopyTradingOrdersModal
open={ordersModalOpen}
onClose={() => setOrdersModalOpen(false)}
copyTradingId={ordersModalCopyTradingId}
defaultTab={ordersModalTab}
/>
<StatisticsModal
open={statisticsModalOpen}
onClose={() => setStatisticsModalOpen(false)}
copyTradingId={statisticsModalCopyTradingId}
/>
<FilteredOrdersModal
open={filteredOrdersModalOpen}
onClose={() => setFilteredOrdersModalOpen(false)}
copyTradingId={filteredOrdersModalCopyTradingId}
/>
<EditModal
open={editModalOpen}
onClose={() => setEditModalOpen(false)}
copyTradingId={editModalCopyTradingId}
onSuccess={() => {
fetchCopyTradings()
}}
/>
</div>
)
}
@@ -0,0 +1,352 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
const { Option } = Select
interface BuyOrdersTabProps {
copyTradingId: string
}
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<BuyOrderInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{
marketId?: string
side?: string
status?: string
}>({})
useEffect(() => {
if (copyTradingId) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
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)
} finally {
setLoading(false)
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '已完成' },
partially_matched: { color: 'warning', text: t('copyTradingOrders.statusPartiallySold') || '部分成交' },
fully_matched: { color: 'success', text: t('copyTradingOrders.statusFullySold') || '全部成交' }
}
const config = statusMap[status] || { color: 'default', text: status }
return <Tag color={config.color}>{config.text}</Tag>
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: t('copyTradingOrders.buyQuantity') || '买入数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyPrice') || '买入价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyAmount') || '买入金额',
key: 'amount',
width: isMobile ? 100 : 120,
render: (_: any, record: BuyOrderInfo) => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return (
<span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
</span>
)
}
},
{
title: t('copyTradingOrders.matchedQuantity') || '已匹配',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.remainingQuantity') || '剩余',
dataIndex: 'remainingQuantity',
key: 'remainingQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellStatus') || '卖出状态',
dataIndex: 'status',
key: 'status',
width: isMobile ? 80 : 100,
render: (status: string) => getStatusTag(status)
},
{
title: t('copyTradingOrders.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
>
<Option value="filled">{t('copyTradingOrders.statusFilled') || '已完成'}</Option>
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noBuyOrders') || '暂无买入订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => {
const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
{getStatusTag(order.status)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyInfo') || '买入信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchInfo') || '匹配信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
loading={loading}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: true,
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</div>
)
}
export default BuyOrdersTab
@@ -0,0 +1,481 @@
import { useEffect, useState } from 'react'
import { Modal, Form, Button, message, Radio, InputNumber, Divider, Spin, Select, Input, Space, Switch } from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../../types'
import { useTranslation } from 'react-i18next'
const { Option } = Select
interface EditModalProps {
open: boolean
onClose: () => void
copyTradingId: string
onSuccess?: () => void
}
const EditModal: React.FC<EditModalProps> = ({
open,
onClose,
copyTradingId,
onSuccess
}) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(true)
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
useEffect(() => {
if (open && copyTradingId) {
fetchCopyTrading(parseInt(copyTradingId))
}
}, [open, copyTradingId])
const fetchCopyTrading = async (copyTradingId: number) => {
setFetching(true)
try {
const response = await apiService.copyTrading.list({})
if (response.data.code === 0 && response.data.data) {
const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId)
if (found) {
setCopyTrading(found)
setCopyMode(found.copyMode)
setOriginalEnabled(found.enabled)
form.setFieldsValue({
accountId: found.accountId,
leaderId: found.leaderId,
copyMode: found.copyMode,
copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100,
fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined,
maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined,
minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined,
maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined,
maxDailyOrders: found.maxDailyOrders,
priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined,
delaySeconds: found.delaySeconds,
pollIntervalSeconds: found.pollIntervalSeconds,
useWebSocket: found.useWebSocket,
websocketReconnectInterval: found.websocketReconnectInterval,
websocketMaxRetries: found.websocketMaxRetries,
supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
onClose()
}
} else {
message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
onClose()
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
onClose()
} finally {
setFetching(false)
}
}
const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => {
setCopyMode(mode)
}
const handleSubmit = async (values: any) => {
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
message.error('固定金额必须 >= 1')
return
}
}
if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) {
message.error('最小金额必须 >= 1')
return
}
if (!copyTradingId) {
message.error('配置ID不存在')
return
}
setLoading(true)
try {
const request: CopyTradingUpdateRequest = {
copyTradingId: parseInt(copyTradingId),
enabled: originalEnabled,
copyMode: values.copyMode,
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined,
maxOrderSize: values.maxOrderSize?.toString(),
minOrderSize: values.minOrderSize?.toString(),
maxDailyLoss: values.maxDailyLoss?.toString(),
maxDailyOrders: values.maxDailyOrders,
priceTolerance: values.priceTolerance?.toString(),
delaySeconds: values.delaySeconds,
pollIntervalSeconds: values.pollIntervalSeconds,
useWebSocket: values.useWebSocket,
websocketReconnectInterval: values.websocketReconnectInterval,
websocketMaxRetries: values.websocketMaxRetries,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
const response = await apiService.copyTrading.update(request)
if (response.data.code === 0) {
message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功')
onClose()
if (onSuccess) {
onSuccess()
}
} else {
message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
} finally {
setLoading(false)
}
}
return (
<Modal
title={t('copyTradingEdit.title') || '编辑跟单配置'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
{fetching ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
) : !copyTrading ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<p>{t('copyTradingEdit.fetchFailed') || '跟单配置不存在'}</p>
</div>
) : (
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
>
<Select disabled>
<Option value={copyTrading.accountId}>
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
name="leaderId"
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
<Form.Item
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
name="copyMode"
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
rules={[{ required: true }]}
>
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
</Radio.Group>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
name="copyRatio"
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
>
<InputNumber
min={10}
max={1000}
step={1}
precision={0}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单)'}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{
validator: (_, value) => {
if (value !== undefined && value !== null && value !== '') {
const amount = Number(value)
if (isNaN(amount)) {
return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字'))
}
if (amount < 1) {
return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1'))
}
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
/>
</Form.Item>
)}
{copyMode === 'RATIO' && (
<>
<Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
>
<InputNumber
min={0.0001}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[
{
validator: (_, value) => {
if (value === undefined || value === null || value === '') {
return Promise.resolve()
}
if (typeof value === 'number' && value < 1) {
return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1'))
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
/>
</Form.Item>
</>
)}
<Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
name="maxDailyOrders"
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
name="priceTolerance"
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
>
<InputNumber
min={0}
max={100}
step={0.1}
precision={2}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
name="delaySeconds"
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.priceRange') || '价格区间'}
name="priceRange"
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
>
<Input.Group compact style={{ display: 'flex' }}>
<Form.Item name="minPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
/>
</Form.Item>
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
<Form.Item name="maxPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
/>
</Form.Item>
</Input.Group>
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('copyTradingEdit.save') || '保存'}
</Button>
<Button onClick={onClose}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
)}
</Modal>
)
}
export default EditModal
@@ -0,0 +1,282 @@
import { useEffect, useState } from 'react'
import { Modal, Table, Tag, Select, Card, Divider, Spin } from 'antd'
import { useTranslation } from 'react-i18next'
import { apiService } from '../../services/api'
import type { FilteredOrder, FilteredOrderListResponse } from '../../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../../utils'
const { Option } = Select
interface FilteredOrdersModalProps {
open: boolean
onClose: () => void
copyTradingId: string
}
const FilteredOrdersModal: React.FC<FilteredOrdersModalProps> = ({
open,
onClose,
copyTradingId
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [filteredOrders, setFilteredOrders] = useState<FilteredOrder[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [filterType, setFilterType] = useState<string | undefined>(undefined)
useEffect(() => {
if (open && copyTradingId) {
fetchFilteredOrders()
}
}, [open, copyTradingId, page, filterType])
const fetchFilteredOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const response = await apiService.copyTrading.getFilteredOrders({
copyTradingId: parseInt(copyTradingId),
filterType: filterType,
page: page,
limit: limit
})
if (response.data.code === 0 && response.data.data) {
const data: FilteredOrderListResponse = response.data.data
setFilteredOrders(data.list || [])
setTotal(data.total || 0)
}
} catch (error: any) {
console.error('获取被过滤订单列表失败:', error)
} finally {
setLoading(false)
}
}
const getFilterTypeTag = (filterType: string) => {
const typeMap: Record<string, { color: string; text: string }> = {
ORDER_DEPTH: { color: 'orange', text: t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足' },
SPREAD: { color: 'red', text: t('filteredOrdersList.filterTypes.spread') || '价差过大' },
ORDERBOOK_DEPTH: { color: 'purple', text: t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足' },
PRICE_VALIDITY: { color: 'blue', text: t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理' },
MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' },
ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' },
ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' },
PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }
}
const config = typeMap[filterType] || { color: 'default', text: filterType }
return <Tag color={config.color}>{config.text}</Tag>
}
const columns = [
{
title: t('filteredOrdersList.market') || '市场',
dataIndex: 'marketTitle',
key: 'marketTitle',
width: isMobile ? 150 : 200,
ellipsis: true,
render: (text: string, record: FilteredOrder) => {
const marketTitle = text || record.marketId.slice(0, 10) + '...'
return <span style={{ fontSize: isMobile ? 12 : 14 }}>{marketTitle}</span>
}
},
{
title: t('filteredOrdersList.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => (
<Tag color={side === 'BUY' ? 'green' : 'red'}>
{side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
</Tag>
)
},
{
title: t('filteredOrdersList.price') || '价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('filteredOrdersList.size') || '数量',
dataIndex: 'size',
key: 'size',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('filteredOrdersList.filterType') || '过滤类型',
dataIndex: 'filterType',
key: 'filterType',
width: isMobile ? 120 : 150,
render: (filterType: string) => getFilterTypeTag(filterType)
},
{
title: t('filteredOrdersList.filterReason') || '过滤原因',
dataIndex: 'filterReason',
key: 'filterReason',
width: isMobile ? 150 : 250,
ellipsis: true,
render: (text: string) => (
<span style={{ fontSize: isMobile ? 11 : 12 }} title={text}>{text}</span>
)
},
{
title: t('filteredOrdersList.createdAt') || '时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => {
const date = new Date(timestamp)
const format = isMobile
? `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
return (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{format}
</span>
)
}
}
]
return (
<Modal
title={t('copyTradingOrders.filteredOrders') || '已过滤订单'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
<div style={{ marginBottom: 16 }}>
<Select
placeholder={t('filteredOrdersList.filterByType') || '按类型筛选'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filterType}
onChange={(value) => setFilterType(value || undefined)}
>
<Option value="ORDER_DEPTH">{t('filteredOrdersList.filterTypes.orderDepth') || '订单深度不足'}</Option>
<Option value="SPREAD">{t('filteredOrdersList.filterTypes.spread') || '价差过大'}</Option>
<Option value="ORDERBOOK_DEPTH">{t('filteredOrdersList.filterTypes.orderbookDepth') || '订单簿深度不足'}</Option>
<Option value="PRICE_VALIDITY">{t('filteredOrdersList.filterTypes.priceValidity') || '价格不合理'}</Option>
<Option value="MARKET_STATUS">{t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易'}</Option>
<Option value="ORDERBOOK_ERROR">{t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败'}</Option>
<Option value="ORDERBOOK_EMPTY">{t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空'}</Option>
<Option value="PRICE_RANGE">{t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符'}</Option>
</Select>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : filteredOrders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('filteredOrdersList.noFilteredOrders') || '暂无已过滤订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{filteredOrders.map((order) => {
const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const marketTitle = order.marketTitle || order.marketId.slice(0, 10) + '...'
return (
<Card
key={order.id}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{marketTitle}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag color={order.side === 'BUY' ? 'green' : 'red'}>
{order.side === 'BUY' ? (t('order.buy') || '买入') : (t('order.sell') || '卖出')}
</Tag>
{getFilterTypeTag(order.filterType)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.orderDetails') || '订单详情'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('filteredOrdersList.price') || '价格'}: {formatUSDC(order.price)} | {t('filteredOrdersList.size') || '数量'}: {formatUSDC(order.size)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('filteredOrdersList.filterReason') || '过滤原因'}</div>
<div style={{ fontSize: '12px', color: '#333' }} title={order.filterReason}>
{order.filterReason}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('filteredOrdersList.createdAt') || '时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={filteredOrders}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: true,
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
onChange: (newPage) => {
setPage(newPage)
}
}}
/>
)}
</Modal>
)
}
export default FilteredOrdersModal
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react'
import { Table, Input, Button, Card, Divider, Spin } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
interface MatchedOrdersTabProps {
copyTradingId: string
}
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<MatchedOrderInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{
sellOrderId?: string
buyOrderId?: string
}>({})
useEffect(() => {
if (copyTradingId) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
copyTradingId: parseInt(copyTradingId),
type: 'matched',
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 MatchedOrderInfo[])
setTotal(data.total || 0)
}
} catch (error: any) {
console.error('获取匹配关系列表失败:', error)
} finally {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const columns = [
{
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
dataIndex: 'sellOrderId',
key: 'sellOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
dataIndex: 'buyOrderId',
key: 'buyOrderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.matchedQuantity') || '匹配数量',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.buyPrice') || '买入价格',
dataIndex: 'buyPrice',
key: 'buyPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellPrice') || '卖出价格',
dataIndex: 'sellPrice',
key: 'sellPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.realizedPnl') || '盈亏',
dataIndex: 'realizedPnl',
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
</span>
)
},
{
title: t('copyTradingOrders.matchedAt') || '匹配时间',
dataIndex: 'matchedAt',
key: 'matchedAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.sellOrderId}
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
/>
<Input
placeholder={t('copyTradingOrders.filterBuyOrderId') || '筛选买入订单ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.buyOrderId}
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
/>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noMatchedOrders') || '暂无匹配关系'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => {
const date = new Date(order.matchedAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return (
<Card
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace',
marginBottom: '8px'
}}>
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace'
}}>
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchedQuantity') || '匹配数量'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{formatUSDC(order.matchedQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.priceInfo') || '价格信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '盈亏'}</div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey={(record) => `${record.sellOrderId}-${record.buyOrderId}-${record.matchedAt}`}
loading={loading}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: true,
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</div>
)
}
export default MatchedOrdersTab
@@ -0,0 +1,328 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Select, Input, Button, Card, Divider, Spin } from 'antd'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
const { Option } = Select
interface SellOrdersTabProps {
copyTradingId: string
}
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [orders, setOrders] = useState<SellOrderInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{
marketId?: string
side?: string
}>({})
useEffect(() => {
if (copyTradingId) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
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)
} finally {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId',
key: 'orderId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID',
dataIndex: 'leaderTradeId',
key: 'leaderTradeId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.market') || '市场',
dataIndex: 'marketId',
key: 'marketId',
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
</span>
)
},
{
title: t('copyTradingOrders.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: t('copyTradingOrders.sellQuantity') || '卖出数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellPrice') || '卖出价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: t('copyTradingOrders.sellAmount') || '卖出金额',
key: 'amount',
width: isMobile ? 100 : 120,
render: (_: any, record: SellOrderInfo) => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return (
<span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
</span>
)
}
},
{
title: t('copyTradingOrders.realizedPnl') || '已实现盈亏',
dataIndex: 'realizedPnl',
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
</span>
)
},
{
title: t('copyTradingOrders.createdAt') || '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
</span>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.side}
onChange={(value) => setFilters({ ...filters, side: value || undefined })}
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
{t('copyTradingOrders.noSellOrders') || '暂无卖出订单'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => {
const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
style={{
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid #e8e8e8'
}}
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellInfo') || '卖出信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}</div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
</div>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
<Table
columns={columns}
dataSource={orders}
rowKey="orderId"
loading={loading}
pagination={{
current: page,
pageSize: limit,
total,
showSizeChanger: true,
showTotal: (total) => `${t('common.total') || '共'} ${total} ${t('common.items') || '条'}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</div>
)
}
export default SellOrdersTab
@@ -0,0 +1,303 @@
import { useEffect, useState } from 'react'
import { Modal, Row, Col, Statistic, Spin, message, Card } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { apiService } from '../../services/api'
import { formatUSDC } from '../../utils'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import type { CopyTradingStatistics } from '../../types'
interface StatisticsModalProps {
open: boolean
onClose: () => void
copyTradingId: string
}
const StatisticsModal: React.FC<StatisticsModalProps> = ({
open,
onClose,
copyTradingId
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
useEffect(() => {
if (open && copyTradingId) {
fetchStatistics()
}
}, [open, copyTradingId])
const fetchStatistics = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const response = await apiService.statistics.detail({ copyTradingId: parseInt(copyTradingId) })
if (response.data.code === 0 && response.data.data) {
setStatistics(response.data.data)
} else {
message.error(response.data.msg || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败')
}
} catch (error: any) {
message.error(error.message || t('copyTradingOrders.fetchStatisticsFailed') || '获取统计信息失败')
} finally {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const getPnlIcon = (value: string) => {
const num = parseFloat(value)
if (isNaN(num)) return null
return num >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />
}
const formatPercent = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '-'
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%`
}
return (
<Modal
title={t('copyTradingOrders.statistics') || '跟单关系统计'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
) : !statistics ? (
<div style={{ textAlign: 'center', padding: '50px' }}>
<p>{t('copyTradingOrders.noStatistics') || '暂无统计数据'}</p>
</div>
) : isMobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalBuyOrders}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalSellOrders}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalMatchedOrders') || '总匹配订单数'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right' }}>
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{statistics.totalMatchedOrders || 0}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalBuyAmount)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalSellAmount)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalPnl') || '总盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalRealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalRealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalRealizedPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalUnrealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalUnrealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalUnrealizedPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.winRate') || '胜率'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right' }}>
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatPercent(statistics.winRate)}</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.averagePnl') || '平均盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.averagePnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.averagePnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.averagePnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.maxPnl') || '最大盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.maxPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.maxPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.maxPnl)} USDC</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px' }}>
<div style={{ fontSize: '14px', color: '#666', flex: '0 0 auto', marginRight: '12px' }}>
{t('copyTradingOrders.minPnl') || '最小盈亏'}
</div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.minPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.minPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.minPnl)} USDC</span>
</div>
</div>
</div>
) : (
<div>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalBuyOrders') || '总买入订单数'}
value={statistics.totalBuyOrders}
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalSellOrders') || '总卖出订单数'}
value={statistics.totalSellOrders}
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalMatchedOrders') || '总匹配订单数'}
value={statistics.totalMatchedOrders || 0}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
value={formatUSDC(statistics.totalBuyAmount)}
suffix="USDC"
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
value={formatUSDC(statistics.totalSellAmount)}
suffix="USDC"
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalPnl') || '总盈亏'}
value={formatUSDC(statistics.totalPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
value={formatUSDC(statistics.totalRealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
prefix={getPnlIcon(statistics.totalRealizedPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
value={formatUSDC(statistics.totalUnrealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.winRate') || '胜率'}
value={formatPercent(statistics.winRate)}
suffix="%"
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.averagePnl') || '平均盈亏'}
value={formatUSDC(statistics.averagePnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.averagePnl) }}
prefix={getPnlIcon(statistics.averagePnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.maxPnl') || '最大盈亏'}
value={formatUSDC(statistics.maxPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.maxPnl) }}
prefix={getPnlIcon(statistics.maxPnl)}
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title={t('copyTradingOrders.minPnl') || '最小盈亏'}
value={formatUSDC(statistics.minPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.minPnl) }}
prefix={getPnlIcon(statistics.minPnl)}
/>
</Col>
</Row>
</div>
)}
</Modal>
)
}
export default StatisticsModal
@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { Modal, Tabs } from 'antd'
import { useTranslation } from 'react-i18next'
import BuyOrdersTab from './BuyOrdersTab'
import SellOrdersTab from './SellOrdersTab'
import MatchedOrdersTab from './MatchedOrdersTab'
type TabType = 'buy' | 'sell' | 'matched'
interface CopyTradingOrdersModalProps {
open: boolean
onClose: () => void
copyTradingId: string
defaultTab?: TabType
}
const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
open,
onClose,
copyTradingId,
defaultTab = 'buy'
}) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>(defaultTab)
useEffect(() => {
if (open) {
setActiveTab(defaultTab)
}
}, [open, defaultTab])
return (
<Modal
title={t('copyTradingOrders.title') || '订单列表'}
open={open}
onCancel={onClose}
footer={null}
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as TabType)}
items={[
{
key: 'buy',
label: t('copyTradingOrders.buyOrders') || '买入订单',
children: <BuyOrdersTab copyTradingId={copyTradingId} />
},
{
key: 'sell',
label: t('copyTradingOrders.sellOrders') || '卖出订单',
children: <SellOrdersTab copyTradingId={copyTradingId} />
},
{
key: 'matched',
label: t('copyTradingOrders.matchedOrders') || '匹配关系',
children: <MatchedOrdersTab copyTradingId={copyTradingId} />
}
]}
/>
</Modal>
)
}
export default CopyTradingOrdersModal