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
+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