feat: Leader列表优化
- 后端过滤价值为0的仓位 - 持仓列表显示市场名称而非ID - 列表移除分类和创建时间列 - 文案'跟单关系数'改为'跟单数' - 持仓DTO添加title字段
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"viewDetail": "View Details",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
"search": "Search",
|
||||
@@ -481,6 +482,37 @@
|
||||
"fetchFailed": "Failed to get Leader details",
|
||||
"invalidId": "Invalid Leader ID"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader Details",
|
||||
"loading": "Loading...",
|
||||
"invalidId": "Invalid Leader ID",
|
||||
"fetchFailed": "Failed to get Leader details",
|
||||
"fetchBalanceFailed": "Failed to get balance",
|
||||
"noBalanceData": "No balance data",
|
||||
"basicInfo": "Basic Information",
|
||||
"leaderName": "Leader Name",
|
||||
"leaderAddress": "Wallet Address",
|
||||
"category": "Category",
|
||||
"copyTradingCount": "Copy Trading Count",
|
||||
"remark": "Remark",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At",
|
||||
"website": "Website",
|
||||
"openWebsite": "Open Website",
|
||||
"balanceInfo": "Balance Information",
|
||||
"refresh": "Refresh",
|
||||
"availableBalance": "Available Balance",
|
||||
"positionBalance": "Position Balance",
|
||||
"totalBalance": "Total Balance",
|
||||
"positions": "Positions",
|
||||
"noPositions": "No positions",
|
||||
"marketId": "Market ID",
|
||||
"side": "Side",
|
||||
"quantity": "Quantity",
|
||||
"avgPrice": "Avg Price",
|
||||
"currentValue": "Current Value",
|
||||
"pnl": "PnL"
|
||||
},
|
||||
"userList": {
|
||||
"title": "User Management",
|
||||
"username": "Username",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "确定",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"viewDetail": "查看详情",
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
"search": "搜索",
|
||||
@@ -216,7 +217,7 @@
|
||||
"walletAddress": "钱包地址",
|
||||
"category": "分类",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单关系数",
|
||||
"copyTradingCount": "跟单数",
|
||||
"createdAt": "创建时间",
|
||||
"action": "操作",
|
||||
"add": "添加",
|
||||
@@ -424,7 +425,7 @@
|
||||
"website": "网站",
|
||||
"openWebsite": "打开网页",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟单关系数",
|
||||
"copyTradingCount": "跟单数",
|
||||
"copyTradingRelations": "{{count}} 个跟单关系",
|
||||
"createdAt": "创建时间",
|
||||
"noData": "暂无 Leader 数据",
|
||||
@@ -481,6 +482,37 @@
|
||||
"fetchFailed": "获取 Leader 详情失败",
|
||||
"invalidId": "Leader ID 无效"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader 详情",
|
||||
"loading": "加载中...",
|
||||
"invalidId": "Leader ID 无效",
|
||||
"fetchFailed": "获取 Leader 详情失败",
|
||||
"fetchBalanceFailed": "获取余额失败",
|
||||
"noBalanceData": "暂无余额数据",
|
||||
"basicInfo": "基本信息",
|
||||
"leaderName": "Leader 名称",
|
||||
"leaderAddress": "钱包地址",
|
||||
"category": "分类",
|
||||
"copyTradingCount": "跟单数",
|
||||
"remark": "备注",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"website": "网站",
|
||||
"openWebsite": "打开网页",
|
||||
"balanceInfo": "余额信息",
|
||||
"refresh": "刷新",
|
||||
"availableBalance": "可用余额",
|
||||
"positionBalance": "仓位余额",
|
||||
"totalBalance": "总余额",
|
||||
"positions": "持仓列表",
|
||||
"noPositions": "暂无持仓",
|
||||
"marketId": "市场ID",
|
||||
"side": "方向",
|
||||
"quantity": "数量",
|
||||
"avgPrice": "均价",
|
||||
"currentValue": "当前价值",
|
||||
"pnl": "盈亏"
|
||||
},
|
||||
"userList": {
|
||||
"title": "用户管理",
|
||||
"username": "用户名",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "編輯",
|
||||
"viewDetail": "查看詳情",
|
||||
"delete": "刪除",
|
||||
"add": "添加",
|
||||
"search": "搜索",
|
||||
@@ -216,7 +217,7 @@
|
||||
"walletAddress": "錢包地址",
|
||||
"category": "分類",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟單關係數",
|
||||
"copyTradingCount": "跟單數",
|
||||
"createdAt": "創建時間",
|
||||
"action": "操作",
|
||||
"add": "添加",
|
||||
@@ -424,7 +425,7 @@
|
||||
"website": "網站",
|
||||
"openWebsite": "打開網頁",
|
||||
"all": "全部",
|
||||
"copyTradingCount": "跟單關係數",
|
||||
"copyTradingCount": "跟單數",
|
||||
"copyTradingRelations": "{{count}} 個跟單關係",
|
||||
"createdAt": "創建時間",
|
||||
"noData": "暫無 Leader 數據",
|
||||
@@ -481,6 +482,37 @@
|
||||
"fetchFailed": "獲取 Leader 詳情失敗",
|
||||
"invalidId": "Leader ID 無效"
|
||||
},
|
||||
"leaderDetail": {
|
||||
"title": "Leader 詳情",
|
||||
"loading": "加載中...",
|
||||
"invalidId": "Leader ID 無效",
|
||||
"fetchFailed": "獲取 Leader 詳情失敗",
|
||||
"fetchBalanceFailed": "獲取餘額失敗",
|
||||
"noBalanceData": "暫無餘額數據",
|
||||
"basicInfo": "基本信息",
|
||||
"leaderName": "Leader 名稱",
|
||||
"leaderAddress": "錢包地址",
|
||||
"category": "分類",
|
||||
"copyTradingCount": "跟單數",
|
||||
"remark": "備註",
|
||||
"createdAt": "創建時間",
|
||||
"updatedAt": "更新時間",
|
||||
"website": "網站",
|
||||
"openWebsite": "打開網頁",
|
||||
"balanceInfo": "餘額信息",
|
||||
"refresh": "刷新",
|
||||
"availableBalance": "可用餘額",
|
||||
"positionBalance": "倉位餘額",
|
||||
"totalBalance": "總餘額",
|
||||
"positions": "持倉列表",
|
||||
"noPositions": "暫無持倉",
|
||||
"marketId": "市場ID",
|
||||
"side": "方向",
|
||||
"quantity": "數量",
|
||||
"avgPrice": "均價",
|
||||
"currentValue": "當前價值",
|
||||
"pnl": "盈虧"
|
||||
},
|
||||
"userList": {
|
||||
"title": "用戶管理",
|
||||
"username": "用戶名",
|
||||
|
||||
+426
-179
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader } from '../types'
|
||||
import type { Leader, LeaderBalanceResponse, PositionDto } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -15,11 +16,19 @@ const LeaderList: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
|
||||
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
|
||||
|
||||
// 详情 Modal
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
const [detailLeader, setDetailLeader] = useState<Leader | null>(null)
|
||||
const [detailBalance, setDetailBalance] = useState<LeaderBalanceResponse | null>(null)
|
||||
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeaders()
|
||||
}, [])
|
||||
|
||||
|
||||
const fetchLeaders = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -27,253 +36,349 @@ const LeaderList: React.FC = () => {
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
|
||||
message.error(response.data.msg || t('leaderList.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderList.fetchFailed') || '获取 Leader 列表失败')
|
||||
message.error(error.message || t('leaderList.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 加载所有 Leader 的余额
|
||||
useEffect(() => {
|
||||
const loadBalances = async () => {
|
||||
for (const leader of leaders) {
|
||||
if (!balanceMap[leader.id] && !balanceLoading[leader.id]) {
|
||||
setBalanceLoading(prev => ({ ...prev, [leader.id]: true }))
|
||||
try {
|
||||
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
|
||||
if (balanceData.data.code === 0 && balanceData.data.data) {
|
||||
setBalanceMap(prev => ({
|
||||
...prev,
|
||||
[leader.id]: {
|
||||
total: balanceData.data.data.totalBalance || '0',
|
||||
available: balanceData.data.data.availableBalance || '0',
|
||||
position: balanceData.data.data.positionBalance || '0'
|
||||
}
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取 Leader ${leader.id} 余额失败:`, error)
|
||||
setBalanceMap(prev => ({
|
||||
...prev,
|
||||
[leader.id]: { total: '-', available: '-', position: '-' }
|
||||
}))
|
||||
} finally {
|
||||
setBalanceLoading(prev => ({ ...prev, [leader.id]: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (leaders.length > 0) {
|
||||
loadBalances()
|
||||
}
|
||||
}, [leaders])
|
||||
|
||||
const handleDelete = async (leaderId: number) => {
|
||||
try {
|
||||
const response = await apiService.leaders.delete({ leaderId })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderList.deleteSuccess') || '删除 Leader 成功')
|
||||
message.success(t('leaderList.deleteSuccess'))
|
||||
fetchLeaders()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderList.deleteFailed') || '删除 Leader 失败')
|
||||
message.error(response.data.msg || t('leaderList.deleteFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderList.deleteFailed') || '删除 Leader 失败')
|
||||
message.error(error.message || t('leaderList.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleShowDetail = async (leader: Leader) => {
|
||||
try {
|
||||
setDetailModalVisible(true)
|
||||
setDetailLeader(leader)
|
||||
setDetailBalance(null)
|
||||
setDetailBalanceLoading(false)
|
||||
|
||||
// 加载详情和余额
|
||||
try {
|
||||
const leaderDetail = await apiService.leaders.detail({ leaderId: leader.id })
|
||||
if (leaderDetail.data.code === 0 && leaderDetail.data.data) {
|
||||
setDetailLeader(leaderDetail.data.data)
|
||||
}
|
||||
|
||||
// 加载余额
|
||||
setDetailBalanceLoading(true)
|
||||
try {
|
||||
const balanceData = await apiService.leaders.balance({ leaderId: leader.id })
|
||||
if (balanceData.data.code === 0 && balanceData.data.data) {
|
||||
setDetailBalance(balanceData.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error)
|
||||
setDetailBalance(null)
|
||||
} finally {
|
||||
setDetailBalanceLoading(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取 Leader 详情失败:', error)
|
||||
message.error(error.message || t('leaderList.fetchFailed'))
|
||||
setDetailModalVisible(false)
|
||||
setDetailLeader(null)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('打开详情失败:', error)
|
||||
message.error(error.message || t('leaderList.openDetailFailed'))
|
||||
setDetailModalVisible(false)
|
||||
setDetailLeader(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshDetailBalance = async () => {
|
||||
if (!detailLeader) return
|
||||
|
||||
setDetailBalanceLoading(true)
|
||||
try {
|
||||
const balanceData = await apiService.leaders.balance({ leaderId: detailLeader.id })
|
||||
if (balanceData.data.code === 0 && balanceData.data.data) {
|
||||
setDetailBalance(balanceData.data.data)
|
||||
message.success(t('leaderDetail.refresh'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderDetail.fetchBalanceFailed'))
|
||||
} finally {
|
||||
setDetailBalanceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(i18n.language || 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getPositionColumns = () => {
|
||||
return [
|
||||
{
|
||||
title: t('leaderDetail.market'),
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string) => {
|
||||
if (!title) return <Text type="secondary">-</Text>
|
||||
const displayText = isMobile && title.length > 20 ? `${title.slice(0, 20)}...` : title
|
||||
return <Text style={{ fontSize: isMobile ? '12px' : '13px' }}>{displayText}</Text>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderDetail.side'),
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
render: (side: string) => {
|
||||
const color = side === 'YES' ? 'green' : 'red'
|
||||
return <Tag color={color}>{side}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderDetail.quantity'),
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
render: (quantity: string) => formatUSDC(quantity)
|
||||
},
|
||||
{
|
||||
title: t('leaderDetail.avgPrice'),
|
||||
dataIndex: 'avgPrice',
|
||||
key: 'avgPrice',
|
||||
render: (price: string) => formatUSDC(price)
|
||||
},
|
||||
{
|
||||
title: t('leaderDetail.currentValue'),
|
||||
dataIndex: 'currentValue',
|
||||
key: 'currentValue',
|
||||
render: (value: string) => formatUSDC(value)
|
||||
},
|
||||
{
|
||||
title: t('leaderDetail.pnl'),
|
||||
dataIndex: 'pnl',
|
||||
key: 'pnl',
|
||||
render: (pnl: string | undefined) => {
|
||||
if (!pnl || pnl === '0') {
|
||||
return <Text type="secondary">-</Text>
|
||||
} else {
|
||||
const numPnl = parseFloat(pnl)
|
||||
const color = numPnl > 0 ? '#52c41a' : '#ff4d4f'
|
||||
return <Text style={{ color }}>{formatUSDC(pnl)}</Text>
|
||||
}
|
||||
const numPnl = parseFloat(pnl)
|
||||
const color = numPnl > 0 ? '#52c41a' : '#ff4d4f'
|
||||
return <Text style={{ color }}>{formatUSDC(pnl)}</Text>
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('leaderList.leaderName') || 'Leader 名称',
|
||||
title: t('leaderList.leaderName'),
|
||||
dataIndex: 'leaderName',
|
||||
key: 'leaderName',
|
||||
render: (text: string, record: Leader) => text || `Leader ${record.id}`
|
||||
},
|
||||
{
|
||||
title: t('leaderList.walletAddress') || '钱包地址',
|
||||
dataIndex: 'leaderAddress',
|
||||
key: 'leaderAddress',
|
||||
render: (address: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? '12px' : '14px' }}>
|
||||
{isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address}
|
||||
</span>
|
||||
width: 150,
|
||||
render: (text: string, record: Leader) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderList.remark') || '备注',
|
||||
title: t('leaderList.remark'),
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
render: (remark: string | undefined) => remark ? (
|
||||
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}>
|
||||
{remark}
|
||||
</Text>
|
||||
) : <Text type="secondary">-</Text>
|
||||
},
|
||||
{
|
||||
title: t('leaderList.copyTradingCount') || '跟单关系数',
|
||||
dataIndex: 'copyTradingCount',
|
||||
key: 'copyTradingCount',
|
||||
render: (count: number) => <Tag>{count}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('leaderList.createdAt') || '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(i18n.language || 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (remark: string | undefined) => {
|
||||
if (!remark) return <Text type="secondary">-</Text>
|
||||
return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</Text>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('common.actions') || '操作',
|
||||
title: t('leaderDetail.totalBalance'),
|
||||
key: 'balance',
|
||||
width: 150,
|
||||
render: (_: any, record: Leader) => {
|
||||
const balance = balanceMap[record.id]
|
||||
if (!balance) return <Spin size="small" />
|
||||
const displayText = balance.total === '-' ? '-' : `${formatUSDC(balance.total)} USDC`
|
||||
return <Text style={{ color: '#52c41a', fontWeight: 'bold', fontSize: '14px' }}>{displayText}</Text>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderList.copyTradingCount'),
|
||||
dataIndex: 'copyTradingCount',
|
||||
key: 'copyTradingCount',
|
||||
width: 100,
|
||||
render: (count: number) => <Tag color="cyan">{count}</Tag>
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'action',
|
||||
width: isMobile ? 150 : 200,
|
||||
width: isMobile ? 180 : 250,
|
||||
fixed: 'right',
|
||||
render: (_: any, record: Leader) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
|
||||
{t('common.viewDetail')}
|
||||
</Button>
|
||||
{record.website && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('leaderList.openWebsite') || '打开网页'}
|
||||
<Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
|
||||
{t('leaderList.openWebsite')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
|
||||
>
|
||||
{t('common.edit') || '编辑'}
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
|
||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) || `该 Leader 还有 ${record.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
|
||||
title={t('leaderList.deleteConfirm')}
|
||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText={t('common.confirm') || '确定'}
|
||||
cancelText={t('common.cancel') || '取消'}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
{t('common.delete') || '删除'}
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<h2 style={{ margin: 0 }}>{t('leaderList.title') || 'Leader 管理'}</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/leaders/add')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{t('leaderList.addLeader') || '添加 Leader'}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
|
||||
{t('leaderList.addLeader')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
<Card style={{ borderRadius: '12px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', border: '1px solid #e8e8e8' }} bodyStyle={{ padding: isMobile ? '12px' : '24px' }}>
|
||||
{isMobile ? (
|
||||
// 移动端卡片布局
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : leaders.length === 0 ? (
|
||||
<Empty description={t('leaderList.noData') || '暂无 Leader 数据'} />
|
||||
<Empty description={t('leaderList.noData')} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={leaders}
|
||||
renderItem={(leader) => {
|
||||
const date = new Date(leader.createdAt)
|
||||
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
const balance = balanceMap[leader.id]
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={leader.id}
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{/* Leader 名称和地址 */}
|
||||
<Card key={leader.id} style={{ marginBottom: '16px', borderRadius: '12px', boxShadow: '0 2px 6px rgba(0,0,0,0.06)', border: '1px solid #f0f0f0' }} bodyStyle={{ padding: '16px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#1890ff'
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
|
||||
{leader.leaderName || `Leader ${leader.id}`}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{leader.leaderAddress}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
{leader.remark && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('leaderList.remark') || '备注'}:
|
||||
</Text>
|
||||
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>
|
||||
{leader.remark}
|
||||
</Text>
|
||||
|
||||
{balance && (
|
||||
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
|
||||
<div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
{t('leaderDetail.totalBalance')}: {balance.total === '-' ? '-' : `${formatUSDC(balance.total)} USDC`}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
{t('leaderDetail.availableBalance')}: {formatUSDC(balance.available)} | {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 跟单关系数 */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
<Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
|
||||
</div>
|
||||
|
||||
{/* 创建时间 */}
|
||||
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}>
|
||||
{t('leaderList.createdAt') || '创建时间'}: {formattedDate}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
{leader.remark && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}:</Text>
|
||||
<Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<Button type="primary" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(leader)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
{t('common.viewDetail')}
|
||||
</Button>
|
||||
{leader.website && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
{t('leaderList.openWebsite') || '打开网页'}
|
||||
<Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
{t('leaderList.openWebsite')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
{t('common.edit') || '编辑'}
|
||||
<Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'}
|
||||
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) || `该 Leader 还有 ${leader.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined}
|
||||
title={t('leaderList.deleteConfirm')}
|
||||
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
|
||||
onConfirm={() => handleDelete(leader.id)}
|
||||
okText={t('common.confirm') || '确定'}
|
||||
cancelText={t('common.cancel') || '取消'}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ flex: 1, minWidth: '80px' }}
|
||||
>
|
||||
{t('common.delete') || '删除'}
|
||||
<Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
@@ -284,22 +389,164 @@ const LeaderList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端表格布局
|
||||
<Table
|
||||
dataSource={leaders}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: true
|
||||
}}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
|
||||
size="large"
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情 Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<WalletOutlined />
|
||||
<span>{t('leaderDetail.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>
|
||||
]}
|
||||
width={isMobile ? '95%' : 1000}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
{!detailLeader ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
title={
|
||||
<Space>
|
||||
<WalletOutlined />
|
||||
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.basicInfo')}</span>
|
||||
</Space>
|
||||
}
|
||||
bordered
|
||||
column={isMobile ? 1 : 2}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
>
|
||||
<Descriptions.Item label={t('leaderDetail.leaderName')}>
|
||||
{detailLeader.leaderName || `Leader ${detailLeader.id}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderDetail.leaderAddress')}>
|
||||
<span style={{ fontFamily: 'monospace' }}>{detailLeader.leaderAddress}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderDetail.copyTradingCount')}>
|
||||
<Tag color="cyan">{detailLeader.copyTradingCount || 0}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderDetail.remark')}>
|
||||
{detailLeader.remark || <Text type="secondary">-</Text>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderDetail.updatedAt')}>
|
||||
{formatTimestamp(detailLeader.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderDetail.website')}>
|
||||
{detailLeader.website ? (
|
||||
<Button type="link" icon={<GlobalOutlined />} onClick={() => window.open(detailLeader.website, '_blank', 'noopener,noreferrer')} style={{ padding: 0 }}>
|
||||
{t('leaderDetail.openWebsite')}
|
||||
</Button>
|
||||
) : <Text type="secondary">-</Text>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 余额信息 */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Space>
|
||||
<WalletOutlined />
|
||||
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.balanceInfo')}</span>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={handleRefreshDetailBalance} loading={detailBalanceLoading}>
|
||||
{t('leaderDetail.refresh')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{detailBalanceLoading && !detailBalance ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : detailBalance ? (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: '16px' }}>
|
||||
<Col xs={24} sm={8} md={6}>
|
||||
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<Statistic
|
||||
title={t('leaderDetail.availableBalance')}
|
||||
value={parseFloat(detailBalance.availableBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} md={6}>
|
||||
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<Statistic
|
||||
title={t('leaderDetail.positionBalance')}
|
||||
value={parseFloat(detailBalance.positionBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} md={6}>
|
||||
<Card bordered={false} style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<Statistic
|
||||
title={t('leaderDetail.totalBalance')}
|
||||
value={parseFloat(detailBalance.totalBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 持仓列表 */}
|
||||
<Divider />
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Space>
|
||||
<span style={{ fontSize: '16px', fontWeight: 'bold' }}>{t('leaderDetail.positions')}</span>
|
||||
<Tag color="blue">{detailBalance.positions?.length || 0}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{detailBalance.positions && detailBalance.positions.length > 0 ? (
|
||||
<Table
|
||||
dataSource={detailBalance.positions}
|
||||
columns={getPositionColumns()}
|
||||
rowKey={(record, index) => `${record.title}-${record.side}-${index}`}
|
||||
pagination={{ pageSize: 10, showSizeChanger: !isMobile }}
|
||||
scroll={{ x: isMobile ? 800 : 'auto' }}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
/>
|
||||
) : (
|
||||
<Empty description={t('leaderDetail.noPositions')} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Empty description={t('leaderDetail.noBalanceData')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderList
|
||||
|
||||
|
||||
@@ -292,32 +292,38 @@ export const apiService = {
|
||||
/**
|
||||
* 添加 Leader
|
||||
*/
|
||||
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
|
||||
add: (data: { leaderAddress: string; leaderName?: string; remark?: string; website?: string; category?: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
|
||||
|
||||
|
||||
/**
|
||||
* 更新 Leader
|
||||
*/
|
||||
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
|
||||
update: (data: { leaderId: number; leaderName?: string; remark?: string; website?: string; category?: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
|
||||
|
||||
|
||||
/**
|
||||
* 删除 Leader
|
||||
*/
|
||||
delete: (data: { leaderId: number }) =>
|
||||
delete: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
|
||||
|
||||
|
||||
/**
|
||||
* 查询 Leader 列表
|
||||
*/
|
||||
list: (data: { category?: string } = {}) =>
|
||||
list: (data: { category?: string } = {}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data),
|
||||
|
||||
|
||||
/**
|
||||
* 查询 Leader 详情
|
||||
*/
|
||||
detail: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data)
|
||||
detail: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data),
|
||||
|
||||
/**
|
||||
* 查询 Leader 余额
|
||||
*/
|
||||
balance: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,53 @@ export interface LeaderListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 持仓信息
|
||||
*/
|
||||
export interface PositionDto {
|
||||
marketId: string
|
||||
title: string // 市场名称
|
||||
side: string // YES 或 NO
|
||||
quantity: string
|
||||
avgPrice: string
|
||||
currentValue: string
|
||||
pnl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 钱包余额响应(通用类,用于 Account 和 Leader)
|
||||
*/
|
||||
export interface WalletBalanceResponse {
|
||||
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
|
||||
positionBalance: string // 仓位余额(持仓总价值)
|
||||
totalBalance: string // 总余额 = 可用余额 + 仓位余额
|
||||
positions?: PositionDto[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
export interface AccountBalanceResponse {
|
||||
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
|
||||
positionBalance: string // 仓位余额(持仓总价值)
|
||||
totalBalance: string // 总余额 = 可用余额 + 仓位余额
|
||||
positions?: PositionDto[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader 余额响应
|
||||
*/
|
||||
export interface LeaderBalanceResponse {
|
||||
leaderId: number
|
||||
leaderAddress: string
|
||||
leaderName?: string
|
||||
availableBalance: string // 可用余额(RPC 查询的 USDC 余额)
|
||||
positionBalance: string // 仓位余额(持仓总价值)
|
||||
totalBalance: string // 总余额 = 可用余额 + 仓位余额
|
||||
positions?: PositionDto[]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Leader 添加请求
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user