删除后端无用日志

- 删除所有 logger.debug 调试日志
- 删除过于详细的 logger.info 常规操作日志
- 保留 logger.error 和 logger.warn 重要错误和警告日志
- 优化日志输出,减少生产环境日志噪音
This commit is contained in:
WrBug
2025-12-02 22:22:39 +08:00
parent dffbc5124f
commit 792ddb8635
36 changed files with 2737 additions and 221 deletions
+8
View File
@@ -18,6 +18,10 @@ import TemplateAdd from './pages/TemplateAdd'
import TemplateEdit from './pages/TemplateEdit'
import CopyTradingList from './pages/CopyTradingList'
import CopyTradingAdd from './pages/CopyTradingAdd'
import CopyTradingStatistics from './pages/CopyTradingStatistics'
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
@@ -136,6 +140,10 @@ function App() {
<Route path="/templates/edit/:id" element={<TemplateEdit />} />
<Route path="/copy-trading" element={<CopyTradingList />} />
<Route path="/copy-trading/add" element={<CopyTradingAdd />} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<CopyTradingStatistics />} />
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<CopyTradingBuyOrders />} />
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<CopyTradingSellOrders />} />
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<CopyTradingMatchedOrders />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/positions" element={<PositionList />} />
<Route path="/statistics" element={<Statistics />} />
+10 -10
View File
@@ -35,7 +35,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
// 获取当前应该打开的父菜单
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
if (path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
return ['/copy-trading-management']
}
return []
@@ -46,7 +46,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
// 当路径变化时,自动打开对应的父菜单
useEffect(() => {
const path = location.pathname
if (path.startsWith('/templates') || path.startsWith('/copy-trading')) {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) {
setOpenKeys(['/copy-trading-management'])
}
}, [location.pathname])
@@ -57,16 +57,16 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <WalletOutlined />,
label: '账户管理'
},
{
key: '/leaders',
icon: <UserOutlined />,
label: 'Leader 管理'
},
{
key: '/copy-trading-management',
icon: <AppstoreOutlined />,
label: '跟单管理',
label: '跟单交易',
children: [
{
key: '/leaders',
icon: <UserOutlined />,
label: 'Leader 管理'
},
{
key: '/templates',
icon: <FileTextOutlined />,
@@ -118,7 +118,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
justifyContent: 'space-between'
}}>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
Polymarket
PolyHermes
</div>
<Button
type="text"
@@ -179,7 +179,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
fontWeight: 'bold',
flexShrink: 0
}}>
Polymarket
PolyHermes
</div>
<Menu
mode="inline"
+372
View File
@@ -0,0 +1,372 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Table, Button, Tag, Select, Input, message, Space, Divider, Spin } from 'antd'
import { LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
const { Option } = Select
const CopyTradingBuyOrdersPage: React.FC = () => {
const { copyTradingId } = useParams<{ copyTradingId: string }>()
const navigate = useNavigate()
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)
} else {
message.error(response.data.msg || '获取买入订单列表失败')
}
} catch (error: any) {
message.error(error.message || '获取买入订单列表失败')
} finally {
setLoading(false)
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
filled: { color: 'processing', text: '已完成' },
partially_matched: { color: 'warning', text: '部分匹配' },
fully_matched: { color: 'success', text: '完全匹配' }
}
const config = statusMap[status] || { color: 'default', text: status }
return <Tag color={config.color}>{config.text}</Tag>
}
const columns = [
{
title: '订单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: '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: '市场',
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: '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
// 将0/1转换为YES/NO
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: '买入数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '买入价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '买入金额',
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: '已匹配',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '剩余',
dataIndex: 'remainingQuantity',
key: 'remainingQuantity',
width: isMobile ? 70 : 90,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: isMobile ? 80 : 100,
render: (status: string) => getStatusTag(status)
},
{
title: '创建时间',
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>
<Card>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
</Button>
<h2 style={{ margin: 0 }}></h2>
</div>
</div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="筛选市场ID"
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder="筛选方向"
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="筛选状态"
allowClear
style={{ width: isMobile ? '100%' : 150 }}
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
>
<Option value="filled"></Option>
<Option value="partially_matched"></Option>
<Option value="fully_matched"></Option>
</Select>
<Button onClick={fetchOrders}></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' }}>
</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' }}
>
{/* 订单ID和状态 */}
<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' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
: {formatUSDC(order.quantity)} | : {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
: {formatUSDC(amount)} USDC
</div>
</div>
{/* 匹配信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '13px', color: '#333' }}>
: {formatUSDC(order.matchedQuantity)} | : {formatUSDC(order.remainingQuantity)}
</div>
</div>
{/* Leader 交易ID */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader ID</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
{/* 市场ID */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>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' }}>
: {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) => `${total}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</Card>
</div>
)
}
export default CopyTradingBuyOrdersPage
+407 -41
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Input } from 'antd'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Input, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { CopyTrading, Account, Leader, CopyTradingTemplate } from '../types'
import type { CopyTrading, Account, Leader, CopyTradingTemplate, CopyTradingStatistics } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Option } = Select
@@ -17,6 +19,8 @@ const CopyTradingList: React.FC = () => {
const [leaders, setLeaders] = useState<Leader[]>([])
const [templates, setTemplates] = useState<CopyTradingTemplate[]>([])
const [loading, setLoading] = useState(false)
const [statisticsMap, setStatisticsMap] = useState<Record<number, CopyTradingStatistics>>({})
const [loadingStatistics, setLoadingStatistics] = useState<Set<number>>(new Set())
const [filters, setFilters] = useState<{
accountId?: number
templateId?: number
@@ -62,7 +66,12 @@ const CopyTradingList: React.FC = () => {
try {
const response = await apiService.copyTrading.list(filters)
if (response.data.code === 0 && response.data.data) {
setCopyTradings(response.data.data.list || [])
const list = response.data.data.list || []
setCopyTradings(list)
// 为每个跟单关系获取统计信息
list.forEach((ct: CopyTrading) => {
fetchStatistics(ct.id)
})
} else {
message.error(response.data.msg || '获取跟单列表失败')
}
@@ -73,6 +82,50 @@ const CopyTradingList: React.FC = () => {
}
}
const fetchStatistics = async (copyTradingId: number) => {
// 如果正在加载或已有数据,跳过
if (loadingStatistics.has(copyTradingId) || statisticsMap[copyTradingId]) {
return
}
setLoadingStatistics(prev => new Set(prev).add(copyTradingId))
try {
const response = await apiService.statistics.detail({ copyTradingId })
if (response.data.code === 0 && response.data.data) {
setStatisticsMap(prev => ({
...prev,
[copyTradingId]: response.data.data
}))
}
} catch (error: any) {
console.error(`获取跟单统计失败: copyTradingId=${copyTradingId}`, error)
} finally {
setLoadingStatistics(prev => {
const next = new Set(prev)
next.delete(copyTradingId)
return next
})
}
}
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)}%`
}
const handleToggleStatus = async (copyTrading: CopyTrading) => {
try {
const response = await apiService.copyTrading.updateStatus({
@@ -108,11 +161,17 @@ const CopyTradingList: React.FC = () => {
{
title: '钱包',
key: 'account',
width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => (
<div>
<div>{record.accountName || `账户 ${record.accountId}`}</div>
<div style={{ fontSize: 12, color: '#999' }}>
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
{record.accountName || `账户 ${record.accountId}`}
</div>
<div style={{ fontSize: isMobile ? 11 : 12, color: '#999', marginTop: 2 }}>
{isMobile
? `${record.walletAddress.slice(0, 4)}...${record.walletAddress.slice(-3)}`
: `${record.walletAddress.slice(0, 6)}...${record.walletAddress.slice(-4)}`
}
</div>
</div>
)
@@ -121,16 +180,25 @@ const CopyTradingList: React.FC = () => {
title: '模板',
dataIndex: 'templateName',
key: 'templateName',
render: (text: string) => <strong>{text}</strong>
width: isMobile ? 100 : 120,
render: (text: string) => (
<strong style={{ fontSize: isMobile ? 13 : 14 }}>{text}</strong>
)
},
{
title: 'Leader',
key: 'leader',
width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => (
<div>
<div>{record.leaderName || `Leader ${record.leaderId}`}</div>
<div style={{ fontSize: 12, color: '#999' }}>
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
<div style={{ fontSize: isMobile ? 13 : 14, fontWeight: 500 }}>
{record.leaderName || `Leader ${record.leaderId}`}
</div>
<div style={{ fontSize: isMobile ? 11 : 12, color: '#999', marginTop: 2 }}>
{isMobile
? `${record.leaderAddress.slice(0, 4)}...${record.leaderAddress.slice(-3)}`
: `${record.leaderAddress.slice(0, 6)}...${record.leaderAddress.slice(-4)}`
}
</div>
</div>
)
@@ -139,6 +207,7 @@ const CopyTradingList: React.FC = () => {
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: isMobile ? 80 : 100,
render: (enabled: boolean, record: CopyTrading) => (
<Switch
checked={enabled}
@@ -148,27 +217,137 @@ const CopyTradingList: React.FC = () => {
/>
)
},
{
title: '总盈亏',
key: 'totalPnl',
width: isMobile ? 100 : 150,
render: (_: any, record: CopyTrading) => {
const stats = statisticsMap[record.id]
if (!stats) {
return loadingStatistics.has(record.id) ? (
<span style={{ fontSize: isMobile ? 11 : 12 }}>...</span>
) : (
<span style={{ fontSize: isMobile ? 11 : 12 }}>-</span>
)
}
return (
<div>
<div style={{
color: getPnlColor(stats.totalPnl),
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: isMobile ? 12 : 14
}}>
{getPnlIcon(stats.totalPnl)}
{isMobile ? formatUSDC(stats.totalPnl) : `${formatUSDC(stats.totalPnl)} USDC`}
</div>
{!isMobile && (
<div style={{
fontSize: 12,
color: getPnlColor(stats.totalPnlPercent),
marginTop: 4
}}>
{formatPercent(stats.totalPnlPercent)}
</div>
)}
</div>
)
}
},
{
title: '操作',
key: 'action',
width: isMobile ? 80 : 100,
render: (_: any, record: CopyTrading) => (
<Popconfirm
title="确定要删除这个跟单关系吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
)
width: isMobile ? 100 : 200,
fixed: 'right' as const,
render: (_: any, record: CopyTrading) => {
const menuItems: MenuProps['items'] = [
{
key: 'statistics',
label: '查看统计',
icon: <BarChartOutlined />,
onClick: () => navigate(`/copy-trading/statistics/${record.id}`)
},
{
key: 'buyOrders',
label: '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: '匹配关系',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
},
{
type: 'divider'
},
{
key: 'delete',
label: (
<Popconfirm
title="确定要删除这个跟单关系吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
onCancel={(e) => e?.stopPropagation()}
>
<span style={{ color: '#ff4d4f' }}></span>
</Popconfirm>
),
danger: true
}
]
return (
<Space size={isMobile ? 'small' : 'middle'} wrap>
{!isMobile && (
<Button
type="link"
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
>
</Button>
)}
<Dropdown menu={{ items: menuItems }} trigger={['click']}>
<Button
type="link"
size="small"
icon={<UnorderedListOutlined />}
>
{isMobile ? '' : '订单'}
</Button>
</Dropdown>
{!isMobile && (
<Popconfirm
title="确定要删除这个跟单关系吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
)}
</Space>
)
}
}
]
@@ -241,18 +420,205 @@ const CopyTradingList: React.FC = () => {
</Select>
</div>
<Table
columns={columns}
dataSource={copyTradings}
rowKey="id"
loading={loading}
pagination={{
pageSize: isMobile ? 10 : 20,
showSizeChanger: !isMobile,
showTotal: (total) => `${total}`
}}
scroll={{ x: isMobile ? 800 : 'auto' }}
/>
{isMobile ? (
// 移动端卡片布局
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
) : copyTradings.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{copyTradings.map((record) => {
const stats = statisticsMap[record.id]
const date = new Date(record.createdAt)
const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return (
<Card
key={record.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'
}}>
{record.templateName}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag color={record.enabled ? 'green' : 'red'}>
{record.enabled ? '启用' : '禁用'}
</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 账户信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{record.accountName || `账户 ${record.accountId}`}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
{record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)}
</div>
</div>
{/* Leader 信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{record.leaderName || `Leader ${record.leaderId}`}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
{record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)}
</div>
</div>
{/* 总盈亏 */}
{stats && (
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(stats.totalPnl),
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{getPnlIcon(stats.totalPnl)}
{formatUSDC(stats.totalPnl)} USDC
</div>
<div style={{
fontSize: '12px',
color: getPnlColor(stats.totalPnlPercent),
marginTop: '4px'
}}>
{formatPercent(stats.totalPnlPercent)}
</div>
</div>
)}
{loadingStatistics.has(record.id) && (
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
...
</div>
)}
{/* 创建时间 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
: {formattedDate}
</div>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Button
type="primary"
size="small"
icon={<BarChartOutlined />}
onClick={() => navigate(`/copy-trading/statistics/${record.id}`)}
style={{ flex: 1, minWidth: '80px' }}
>
</Button>
<Dropdown
menu={{
items: [
{
key: 'statistics',
label: '查看统计',
icon: <BarChartOutlined />,
onClick: () => navigate(`/copy-trading/statistics/${record.id}`)
},
{
key: 'buyOrders',
label: '买入订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/buy/${record.id}`)
},
{
key: 'sellOrders',
label: '卖出订单',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/sell/${record.id}`)
},
{
key: 'matchedOrders',
label: '匹配关系',
icon: <UnorderedListOutlined />,
onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`)
}
]
}}
trigger={['click']}
>
<Button
size="small"
icon={<UnorderedListOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
</Button>
</Dropdown>
<Popconfirm
title="确定要删除这个跟单关系吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
</Button>
</Popconfirm>
</div>
</Card>
)
})}
</div>
)}
</div>
) : (
// 桌面端表格布局
<Table
columns={columns}
dataSource={copyTradings}
rowKey="id"
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: true,
showTotal: (total) => `${total}`
}}
/>
)}
</Card>
</div>
)
@@ -0,0 +1,305 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Table, Button, Input, message, Divider, Spin } from 'antd'
import { LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
const CopyTradingMatchedOrdersPage: React.FC = () => {
const { copyTradingId } = useParams<{ copyTradingId: string }>()
const navigate = useNavigate()
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)
} else {
message.error(response.data.msg || '获取匹配关系列表失败')
}
} catch (error: any) {
message.error(error.message || '获取匹配关系列表失败')
} 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: '卖出订单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: '买入订单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: '匹配数量',
dataIndex: 'matchedQuantity',
key: 'matchedQuantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '买入价格',
dataIndex: 'buyPrice',
key: 'buyPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '卖出价格',
dataIndex: 'sellPrice',
key: 'sellPrice',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '盈亏',
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: '匹配时间',
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>
<Card>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
</Button>
<h2 style={{ margin: 0 }}></h2>
</div>
</div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="筛选卖出订单ID"
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.sellOrderId}
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
/>
<Input
placeholder="筛选买入订单ID"
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.buyOrderId}
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
/>
<Button onClick={fetchOrders}></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' }}>
</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' }}
>
{/* 订单ID */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>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' }}>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' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{formatUSDC(order.matchedQuantity)}
</div>
</div>
{/* 价格信息 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{ fontSize: '13px', color: '#333' }}>
: {formatUSDC(order.buyPrice)} | : {formatUSDC(order.sellPrice)}
</div>
</div>
{/* 盈亏 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></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' }}>
: {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) => `${total}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</Card>
</div>
)
}
export default CopyTradingMatchedOrdersPage
@@ -0,0 +1,348 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Table, Button, Tag, Select, Input, message, Divider, Spin } from 'antd'
import { LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../types'
const { Option } = Select
const CopyTradingSellOrdersPage: React.FC = () => {
const { copyTradingId } = useParams<{ copyTradingId: string }>()
const navigate = useNavigate()
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)
} else {
message.error(response.data.msg || '获取卖出订单列表失败')
}
} catch (error: any) {
message.error(error.message || '获取卖出订单列表失败')
} 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: '订单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: '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: '市场',
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: '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
// 将0/1转换为YES/NO
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{
title: '卖出数量',
dataIndex: 'quantity',
key: 'quantity',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '卖出价格',
dataIndex: 'price',
key: 'price',
width: isMobile ? 80 : 100,
render: (value: string) => (
<span style={{ fontSize: isMobile ? 12 : 14 }}>{formatUSDC(value)}</span>
)
},
{
title: '卖出金额',
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: '已实现盈亏',
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: '创建时间',
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>
<Card>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate(`/copy-trading/statistics/${copyTradingId}`)}>
</Button>
<h2 style={{ margin: 0 }}></h2>
</div>
</div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="筛选市场ID"
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder="筛选方向"
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}></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' }}>
</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' }}
>
{/* 订单ID和方向 */}
<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' }}></div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
: {formatUSDC(order.quantity)} | : {formatUSDC(order.price)}
</div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
: {formatUSDC(amount)} USDC
</div>
</div>
{/* 已实现盈亏 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}></div>
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
{/* Leader 交易ID */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Leader ID</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
{/* 市场ID */}
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>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' }}>
: {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) => `${total}`,
onChange: (newPage, newLimit) => {
setPage(newPage)
setLimit(newLimit)
}
}}
/>
)}
</Card>
</div>
)
}
export default CopyTradingSellOrdersPage
@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Card, Row, Col, Statistic, Tag, Button, message, Spin } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { formatUSDC } from '../utils'
import { useMediaQuery } from 'react-responsive'
import type { CopyTradingStatistics } from '../types'
const CopyTradingStatisticsPage: React.FC = () => {
const { copyTradingId } = useParams<{ copyTradingId: string }>()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
const [statistics, setStatistics] = useState<CopyTradingStatistics | null>(null)
useEffect(() => {
if (copyTradingId) {
fetchStatistics()
}
}, [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 || '获取统计信息失败')
}
} catch (error: any) {
message.error(error.message || '获取统计信息失败')
} 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)}%`
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
)
}
if (!statistics) {
return (
<Card>
<div style={{ textAlign: 'center', padding: '50px' }}>
<p></p>
<Button onClick={() => navigate('/copy-trading')}></Button>
</div>
</Card>
)
}
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Button icon={<LeftOutlined />} onClick={() => navigate('/copy-trading')}>
</Button>
<h2 style={{ margin: 0 }}></h2>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => navigate(`/copy-trading/orders/buy/${copyTradingId}`)}>
</Button>
<Button onClick={() => navigate(`/copy-trading/orders/sell/${copyTradingId}`)}>
</Button>
<Button onClick={() => navigate(`/copy-trading/orders/matched/${copyTradingId}`)}>
</Button>
</div>
</div>
</Card>
{/* 基本信息卡片 */}
<Card title="基本信息" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<div>
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}></div>
<div style={{ fontSize: 16, fontWeight: 500 }}>
{statistics.accountName || `账户 ${statistics.accountId}`}
</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div>
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>Leader </div>
<div style={{ fontSize: 16, fontWeight: 500 }}>
{statistics.leaderName || `Leader ${statistics.leaderId}`}
</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div>
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}></div>
<div style={{ fontSize: 16, fontWeight: 500 }}>
{statistics.templateName || `模板 ${statistics.templateId}`}
</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div>
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}></div>
<div>
<Tag color={statistics.enabled ? 'green' : 'red'}>
{statistics.enabled ? '启用' : '禁用'}
</Tag>
</div>
</div>
</Col>
</Row>
</Card>
{/* 买入统计卡片 */}
<Card title="买入统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入数量"
value={formatUSDC(statistics.totalBuyQuantity)}
suffix=""
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入金额"
value={formatUSDC(statistics.totalBuyAmount)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总买入订单数"
value={statistics.totalBuyOrders}
suffix="笔"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 卖出统计卡片 */}
<Card title="卖出统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出数量"
value={formatUSDC(statistics.totalSellQuantity)}
suffix=""
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出金额"
value={formatUSDC(statistics.totalSellAmount)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title="总卖出订单数"
value={statistics.totalSellOrders}
suffix="笔"
/>
</Col>
</Row>
</Card>
{/* 持仓统计卡片 */}
<Card title="持仓统计" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={8}>
<Statistic
title="当前持仓数量"
value={formatUSDC(statistics.currentPositionQuantity)}
suffix=""
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title="当前持仓价值"
value={formatUSDC(statistics.currentPositionValue)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={8}>
<Statistic
title="平均买入价格"
value={formatUSDC(statistics.avgBuyPrice)}
suffix=""
/>
</Col>
</Row>
</Card>
{/* 盈亏统计卡片 */}
<Card title="盈亏统计">
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总已实现盈亏"
value={formatUSDC(statistics.totalRealizedPnl)}
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
prefix={getPnlIcon(statistics.totalRealizedPnl)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总未实现盈亏"
value={formatUSDC(statistics.totalUnrealizedPnl)}
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总盈亏"
value={formatUSDC(statistics.totalPnl)}
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)}
suffix="USDC"
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Statistic
title="总盈亏百分比"
value={formatPercent(statistics.totalPnlPercent)}
valueStyle={{ color: getPnlColor(statistics.totalPnlPercent) }}
prefix={getPnlIcon(statistics.totalPnlPercent)}
/>
</Col>
</Row>
</Card>
</div>
)
}
export default CopyTradingStatisticsPage
+18 -1
View File
@@ -284,7 +284,24 @@ export const apiService = {
* 获取分类统计
*/
category: (data: { category: string; startTime?: number; endTime?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data)
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data),
/**
* 获取跟单关系统计详情
*/
detail: (data: { copyTradingId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/detail', data)
},
/**
* 订单跟踪 API
*/
orderTracking: {
/**
* 查询订单列表(买入/卖出/匹配)
*/
list: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/orders/tracking', data)
}
}
+105
View File
@@ -496,3 +496,108 @@ export interface RedeemablePositionsSummary {
positions: RedeemablePositionInfo[]
}
/**
* 跟单关系统计信息
*/
export interface CopyTradingStatistics {
copyTradingId: number
accountId: number
accountName: string | null
leaderId: number
leaderName: string | null
templateId: number
templateName: string | null
enabled: boolean
// 买入统计
totalBuyQuantity: string
totalBuyOrders: number
totalBuyAmount: string
avgBuyPrice: string
// 卖出统计
totalSellQuantity: string
totalSellOrders: number
totalSellAmount: string
// 持仓统计
currentPositionQuantity: string
currentPositionValue: string
// 盈亏统计
totalRealizedPnl: string
totalUnrealizedPnl: string
totalPnl: string
totalPnlPercent: string
}
/**
* 买入订单信息
*/
export interface BuyOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
side: string
quantity: string
price: string
amount: string
matchedQuantity: string
remainingQuantity: string
status: 'filled' | 'partially_matched' | 'fully_matched'
createdAt: number
}
/**
* 卖出订单信息
*/
export interface SellOrderInfo {
orderId: string
leaderTradeId: string
marketId: string
side: string
quantity: string
price: string
amount: string
realizedPnl: string
createdAt: number
}
/**
* 匹配订单信息
*/
export interface MatchedOrderInfo {
sellOrderId: string
buyOrderId: string
matchedQuantity: string
buyPrice: string
sellPrice: string
realizedPnl: string
matchedAt: number
}
/**
* 订单跟踪列表响应
*/
export interface OrderTrackingListResponse {
list: BuyOrderInfo[] | SellOrderInfo[] | MatchedOrderInfo[]
total: number
page: number
limit: number
}
/**
* 订单跟踪查询请求
*/
export interface OrderTrackingRequest {
copyTradingId: number
type: 'buy' | 'sell' | 'matched'
page?: number
limit?: number
marketId?: string
side?: string
status?: string
sellOrderId?: string
buyOrderId?: string
}