- 新增市场信息表和服务,支持市场名称缓存和自动更新 - 创建 Market 实体和 MarketRepository - 实现 MarketService 提供市场信息查询和缓存 - 实现 MarketPollingService 每30秒自动检查并更新缺失的市场信息 - 添加数据库迁移 V19 创建 markets 表 - 订单列表显示市场名称 - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo 中添加 marketTitle 字段 - 订单查询时自动查询并填充市场名称 - 前端订单列表显示市场名称(优先显示名称,ID作为辅助信息) - 前端订单ID复制功能 - 添加 isAutoGeneratedOrderId 和 copyToClipboard 工具函数 - 非自动生成的订单ID支持一键复制 - 买入、卖出、匹配订单列表均支持复制功能 - 仓位检查延迟检测机制优化 - 首次检测到仓位不存在时先记录,3分钟后再次检查 - 避免因API延迟导致的误判
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Table, Tag, Select, Input, Button, Card, Divider, Spin, message } from 'antd'
|
|
import { apiService } from '../../services/api'
|
|
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
|
|
import { useMediaQuery } from 'react-responsive'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
|
|
|
const { Option } = Select
|
|
|
|
import { ReloadOutlined, CopyOutlined } from '@ant-design/icons'
|
|
|
|
interface BuyOrdersTabProps {
|
|
copyTradingId: string
|
|
active?: boolean
|
|
}
|
|
|
|
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = false }) => {
|
|
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 && active) {
|
|
fetchOrders()
|
|
}
|
|
}, [copyTradingId, active, 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 handleCopyOrderId = async (orderId: string) => {
|
|
const success = await copyToClipboard(orderId)
|
|
if (success) {
|
|
message.success(t('common.copySuccess') || '已复制到剪贴板')
|
|
} else {
|
|
message.error(t('common.copyFailed') || '复制失败')
|
|
}
|
|
}
|
|
|
|
const columns = [
|
|
{
|
|
title: t('copyTradingOrders.orderId') || '订单ID',
|
|
dataIndex: 'orderId',
|
|
key: 'orderId',
|
|
width: isMobile ? 120 : 180,
|
|
render: (text: string) => {
|
|
const isAuto = isAutoGeneratedOrderId(text)
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
|
{isMobile
|
|
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
|
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
|
}
|
|
</span>
|
|
{!isAuto && (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => handleCopyOrderId(text)}
|
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
|
title={t('common.copy') || '复制'}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
},
|
|
{
|
|
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 ? 120 : 200,
|
|
render: (text: string, record: BuyOrderInfo) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
{record.marketTitle ? (
|
|
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
|
|
{record.marketTitle}
|
|
</span>
|
|
) : null}
|
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
|
|
{isMobile
|
|
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
|
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
|
}
|
|
</span>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
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>
|
|
</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 type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</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',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px'
|
|
}}>
|
|
<span>{order.orderId.slice(0, 8)}...{order.orderId.slice(-6)}</span>
|
|
{!isAutoGeneratedOrderId(order.orderId) && (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => handleCopyOrderId(order.orderId)}
|
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
title={t('common.copy') || '复制'}
|
|
/>
|
|
)}
|
|
</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.market') || '市场'}</div>
|
|
{order.marketTitle ? (
|
|
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
|
{order.marketTitle}
|
|
</div>
|
|
) : null}
|
|
<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
|
|
|