feat: Leader列表优化

- 后端过滤价值为0的仓位
- 持仓列表显示市场名称而非ID
- 列表移除分类和创建时间列
- 文案'跟单关系数'改为'跟单数'
- 持仓DTO添加title字段
This commit is contained in:
WrBug
2026-01-30 22:03:50 +08:00
parent fa1a915e9c
commit 0bdc0c74d1
12 changed files with 780 additions and 257 deletions
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) { if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource)) return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
} }
val result = leaderService.getLeaderDetail(request.leaderId) val result = leaderService.getLeaderDetail(request.leaderId)
result.fold( result.fold(
onSuccess = { leader -> onSuccess = { leader ->
@@ -159,6 +159,36 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource)) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
} }
} }
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
} }
/** /**
@@ -93,6 +93,16 @@ data class AccountListResponse(
val total: Long val total: Long
) )
/**
* 钱包余额响应(通用类,用于 Account 和 Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/** /**
* 账户余额响应 * 账户余额响应
*/ */
@@ -108,6 +118,7 @@ data class AccountBalanceResponse(
*/ */
data class PositionDto( data class PositionDto(
val marketId: String, val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO val side: String, // YES 或 NO
val quantity: String, val quantity: String,
val avgPrice: String, val avgPrice: String,
@@ -36,6 +36,13 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto val category: String? = null // sports 或 crypto
) )
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/** /**
* Leader 信息响应 * Leader 信息响应
*/ */
@@ -61,3 +68,16 @@ data class LeaderListResponse(
val total: Long val total: Long
) )
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -278,7 +278,7 @@ class AccountService(
if (accountId == null) { if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空")) return Result.failure(IllegalArgumentException("账户ID不能为空"))
} }
val account = accountRepository.findById(accountId).orElse(null) val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在")) ?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -288,68 +288,19 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址")) return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
} }
// 查询 USDC 余额和持仓信息 // 使用通用方法查询余额
val balanceResult = runBlocking { val balanceResult = runBlocking {
try { blockchainService.getWalletBalance(account.proxyAddress)
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
} }
Result.success(balanceResult) balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) { } catch (e: Exception) {
logger.error("查询账户余额失败", e) logger.error("查询账户余额失败", e)
Result.failure(e) Result.failure(e)
@@ -8,9 +8,12 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.util.EthereumUtils import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService import com.wrbug.polymarketbot.service.system.RpcNodeService
@@ -255,7 +258,6 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空")) return Result.failure(IllegalArgumentException("代理地址不能为空"))
} }
// 使用 RPC 查询 USDC 余额(使用代理地址) // 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress) val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance) Result.success(balance)
@@ -264,6 +266,82 @@ class BlockchainService(
Result.failure(e) Result.failure(e)
} }
} }
/**
* 查询钱包余额(通用方法)
* 用于 Account 和 Leader 的余额查询
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/** /**
* 通过 RPC 查询 USDC 余额 * 通过 RPC 查询 USDC 余额
@@ -5,10 +5,12 @@ import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/** /**
* Leader 管理服务 * Leader 管理服务
@@ -17,7 +19,8 @@ import org.springframework.transaction.annotation.Transactional
class LeaderService( class LeaderService(
private val leaderRepository: LeaderRepository, private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository private val copyTradingRepository: CopyTradingRepository,
private val blockchainService: BlockchainService
) { ) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java) private val logger = LoggerFactory.getLogger(LeaderService::class.java)
@@ -176,7 +179,7 @@ class LeaderService(
return try { return try {
val leader = leaderRepository.findById(leaderId).orElse(null) val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在")) ?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId) val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount)) Result.success(toDto(leader, copyTradingCount))
} catch (e: Exception) { } catch (e: Exception) {
@@ -184,6 +187,40 @@ class LeaderService(
Result.failure(e) Result.failure(e)
} }
} }
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/** /**
* 转换为 DTO * 转换为 DTO
+32
View File
@@ -4,6 +4,7 @@
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"edit": "Edit", "edit": "Edit",
"viewDetail": "View Details",
"delete": "Delete", "delete": "Delete",
"add": "Add", "add": "Add",
"search": "Search", "search": "Search",
@@ -481,6 +482,37 @@
"fetchFailed": "Failed to get Leader details", "fetchFailed": "Failed to get Leader details",
"invalidId": "Invalid Leader ID" "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": { "userList": {
"title": "User Management", "title": "User Management",
"username": "Username", "username": "Username",
+34 -2
View File
@@ -6,6 +6,7 @@
"confirm": "确定", "confirm": "确定",
"delete": "删除", "delete": "删除",
"edit": "编辑", "edit": "编辑",
"viewDetail": "查看详情",
"add": "添加", "add": "添加",
"refresh": "刷新", "refresh": "刷新",
"search": "搜索", "search": "搜索",
@@ -216,7 +217,7 @@
"walletAddress": "钱包地址", "walletAddress": "钱包地址",
"category": "分类", "category": "分类",
"all": "全部", "all": "全部",
"copyTradingCount": "跟单关系数", "copyTradingCount": "跟单数",
"createdAt": "创建时间", "createdAt": "创建时间",
"action": "操作", "action": "操作",
"add": "添加", "add": "添加",
@@ -424,7 +425,7 @@
"website": "网站", "website": "网站",
"openWebsite": "打开网页", "openWebsite": "打开网页",
"all": "全部", "all": "全部",
"copyTradingCount": "跟单关系数", "copyTradingCount": "跟单数",
"copyTradingRelations": "{{count}} 个跟单关系", "copyTradingRelations": "{{count}} 个跟单关系",
"createdAt": "创建时间", "createdAt": "创建时间",
"noData": "暂无 Leader 数据", "noData": "暂无 Leader 数据",
@@ -481,6 +482,37 @@
"fetchFailed": "获取 Leader 详情失败", "fetchFailed": "获取 Leader 详情失败",
"invalidId": "Leader ID 无效" "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": { "userList": {
"title": "用户管理", "title": "用户管理",
"username": "用户名", "username": "用户名",
+34 -2
View File
@@ -4,6 +4,7 @@
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"edit": "編輯", "edit": "編輯",
"viewDetail": "查看詳情",
"delete": "刪除", "delete": "刪除",
"add": "添加", "add": "添加",
"search": "搜索", "search": "搜索",
@@ -216,7 +217,7 @@
"walletAddress": "錢包地址", "walletAddress": "錢包地址",
"category": "分類", "category": "分類",
"all": "全部", "all": "全部",
"copyTradingCount": "跟單關係數", "copyTradingCount": "跟單數",
"createdAt": "創建時間", "createdAt": "創建時間",
"action": "操作", "action": "操作",
"add": "添加", "add": "添加",
@@ -424,7 +425,7 @@
"website": "網站", "website": "網站",
"openWebsite": "打開網頁", "openWebsite": "打開網頁",
"all": "全部", "all": "全部",
"copyTradingCount": "跟單關係數", "copyTradingCount": "跟單數",
"copyTradingRelations": "{{count}} 個跟單關係", "copyTradingRelations": "{{count}} 個跟單關係",
"createdAt": "創建時間", "createdAt": "創建時間",
"noData": "暫無 Leader 數據", "noData": "暫無 Leader 數據",
@@ -481,6 +482,37 @@
"fetchFailed": "獲取 Leader 詳情失敗", "fetchFailed": "獲取 Leader 詳情失敗",
"invalidId": "Leader ID 無效" "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": { "userList": {
"title": "用戶管理", "title": "用戶管理",
"username": "用戶名", "username": "用戶名",
+426 -179
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography } from 'antd' 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 } from '@ant-design/icons' import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { Leader } from '../types' import type { Leader, LeaderBalanceResponse, PositionDto } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
const { Text } = Typography const { Text } = Typography
@@ -15,11 +16,19 @@ const LeaderList: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const [leaders, setLeaders] = useState<Leader[]>([]) const [leaders, setLeaders] = useState<Leader[]>([])
const [loading, setLoading] = useState(false) 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(() => { useEffect(() => {
fetchLeaders() fetchLeaders()
}, []) }, [])
const fetchLeaders = async () => { const fetchLeaders = async () => {
setLoading(true) setLoading(true)
try { try {
@@ -27,253 +36,349 @@ const LeaderList: React.FC = () => {
if (response.data.code === 0 && response.data.data) { if (response.data.code === 0 && response.data.data) {
setLeaders(response.data.data.list || []) setLeaders(response.data.data.list || [])
} else { } else {
message.error(response.data.msg || t('leaderList.fetchFailed') || '获取 Leader 列表失败') message.error(response.data.msg || t('leaderList.fetchFailed'))
} }
} catch (error: any) { } catch (error: any) {
message.error(error.message || t('leaderList.fetchFailed') || '获取 Leader 列表失败') message.error(error.message || t('leaderList.fetchFailed'))
} finally { } finally {
setLoading(false) 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) => { const handleDelete = async (leaderId: number) => {
try { try {
const response = await apiService.leaders.delete({ leaderId }) const response = await apiService.leaders.delete({ leaderId })
if (response.data.code === 0) { if (response.data.code === 0) {
message.success(t('leaderList.deleteSuccess') || '删除 Leader 成功') message.success(t('leaderList.deleteSuccess'))
fetchLeaders() fetchLeaders()
} else { } else {
message.error(response.data.msg || t('leaderList.deleteFailed') || '删除 Leader 失败') message.error(response.data.msg || t('leaderList.deleteFailed'))
} }
} catch (error: any) { } 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 = [ const columns = [
{ {
title: t('leaderList.leaderName') || 'Leader 名称', title: t('leaderList.leaderName'),
dataIndex: 'leaderName', dataIndex: 'leaderName',
key: 'leaderName', key: 'leaderName',
render: (text: string, record: Leader) => text || `Leader ${record.id}` width: 150,
}, render: (text: string, record: Leader) => (
{ <Space direction="vertical" size={0}>
title: t('leaderList.walletAddress') || '钱包地址', <Text strong style={{ fontSize: '14px' }}>{text || `Leader ${record.id}`}</Text>
dataIndex: 'leaderAddress', <Text type="secondary" style={{ fontSize: '12px' }}>{record.leaderAddress}</Text>
key: 'leaderAddress', </Space>
render: (address: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? '12px' : '14px' }}>
{isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address}
</span>
) )
}, },
{ {
title: t('leaderList.remark') || '备注', title: t('leaderList.remark'),
dataIndex: 'remark', dataIndex: 'remark',
key: 'remark', key: 'remark',
render: (remark: string | undefined) => remark ? ( width: 200,
<Text ellipsis={{ tooltip: remark }} style={{ maxWidth: isMobile ? 100 : 200 }}> ellipsis: true,
{remark} render: (remark: string | undefined) => {
</Text> if (!remark) return <Text type="secondary">-</Text>
) : <Text type="secondary">-</Text> return <Text ellipsis={{ tooltip: remark }} style={{ maxWidth: 180 }}>{remark}</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'
})
} }
}, },
{ {
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', key: 'action',
width: isMobile ? 150 : 200, width: isMobile ? 180 : 250,
fixed: 'right',
render: (_: any, record: Leader) => ( render: (_: any, record: Leader) => (
<Space size="small" wrap> <Space size="small" wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleShowDetail(record)}>
{t('common.viewDetail')}
</Button>
{record.website && ( {record.website && (
<Button <Button type="link" size="small" icon={<GlobalOutlined />} onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}>
type="link" {t('leaderList.openWebsite')}
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(record.website, '_blank', 'noopener,noreferrer')}
>
{t('leaderList.openWebsite') || '打开网页'}
</Button> </Button>
)} )}
<Button <Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${record.id}`)}>
type="link" {t('common.edit')}
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
>
{t('common.edit') || '编辑'}
</Button> </Button>
<Popconfirm <Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'} title={t('leaderList.deleteConfirm')}
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) || `该 Leader 还有 ${record.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined} description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(record.id)} onConfirm={() => handleDelete(record.id)}
okText={t('common.confirm') || '确定'} okText={t('common.confirm')}
cancelText={t('common.cancel') || '取消'} cancelText={t('common.cancel')}
> >
<Button type="link" size="small" danger icon={<DeleteOutlined />}> <Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('common.delete') || '删除'} {t('common.delete')}
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
) )
} }
] ]
return ( return (
<div> <div>
<div style={{ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
display: 'flex', <h2 style={{ margin: 0, fontSize: isMobile ? '20px' : '24px' }}>{t('leaderList.title')}</h2>
justifyContent: 'space-between', <Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/leaders/add')} size={isMobile ? 'middle' : 'large'} style={{ borderRadius: '8px', height: isMobile ? '40px' : '48px', fontSize: isMobile ? '14px' : '16px' }}>
alignItems: 'center', {t('leaderList.addLeader')}
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'}
</Button> </Button>
</div> </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 ? ( {isMobile ? (
// 移动端卡片布局
<div> <div>
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}> <div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : leaders.length === 0 ? ( ) : leaders.length === 0 ? (
<Empty description={t('leaderList.noData') || '暂无 Leader 数据'} /> <Empty description={t('leaderList.noData')} />
) : ( ) : (
<List <List
dataSource={leaders} dataSource={leaders}
renderItem={(leader) => { renderItem={(leader) => {
const date = new Date(leader.createdAt) const balance = balanceMap[leader.id]
const formattedDate = date.toLocaleString(i18n.language || 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return ( return (
<Card <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' }}>
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 名称和地址 */}
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<div style={{ <div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '6px', color: '#1890ff' }}>
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
color: '#1890ff'
}}>
{leader.leaderName || `Leader ${leader.id}`} {leader.leaderName || `Leader ${leader.id}`}
</div> </div>
<div style={{ <div style={{ fontSize: '12px', color: '#666', fontFamily: 'monospace', wordBreak: 'break-all' }}>
fontSize: '12px',
color: '#666',
fontFamily: 'monospace',
wordBreak: 'break-all'
}}>
{leader.leaderAddress} {leader.leaderAddress}
</div> </div>
</div> </div>
{/* 备注 */} {balance && (
{leader.remark && ( <div style={{ marginBottom: '12px', padding: '12px', backgroundColor: '#f6ffed', borderRadius: '8px', border: '1px solid #b7eb8f' }}>
<div style={{ marginBottom: '12px' }}> <div style={{ fontSize: '13px', color: '#52c41a', fontWeight: 'bold', marginBottom: '4px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}> {t('leaderDetail.totalBalance')}: {balance.total === '-' ? '-' : `${formatUSDC(balance.total)} USDC`}
{t('leaderList.remark') || '备注'} </div>
</Text> <div style={{ fontSize: '11px', color: '#666' }}>
<Text style={{ fontSize: '12px', marginLeft: '4px' }}> {t('leaderDetail.availableBalance')}: {formatUSDC(balance.available)} | {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
{leader.remark} </div>
</Text>
</div> </div>
)} )}
<Divider style={{ margin: '12px 0' }} /> <Divider style={{ margin: '12px 0' }} />
{/* 跟单关系数 */} <div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<div style={{ marginBottom: '12px' }}> <Tag color="cyan">{leader.copyTradingCount} {t('leaderList.copyTradingCount')}</Tag>
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
</div> </div>
{/* 创建时间 */} {leader.remark && (
<div style={{ marginBottom: '12px', fontSize: '12px', color: '#999' }}> <div style={{ marginBottom: '12px' }}>
{t('leaderList.createdAt') || '创建时间'}: {formattedDate} <Text type="secondary" style={{ fontSize: '12px' }}>{t('leaderList.remark')}</Text>
</div> <Text style={{ fontSize: '12px', marginLeft: '4px' }}>{leader.remark}</Text>
</div>
{/* 操作按钮 */} )}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <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 && ( {leader.website && (
<Button <Button type="default" size="small" icon={<GlobalOutlined />} onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
type="link" {t('leaderList.openWebsite')}
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(leader.website, '_blank', 'noopener,noreferrer')}
style={{ flex: 1, minWidth: '80px' }}
>
{t('leaderList.openWebsite') || '打开网页'}
</Button> </Button>
)} )}
<Button <Button type="default" size="small" icon={<EditOutlined />} onClick={() => navigate(`/leaders/edit?id=${leader.id}`)} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
type="link" {t('common.edit')}
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/leaders/edit?id=${leader.id}`)}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.edit') || '编辑'}
</Button> </Button>
<Popconfirm <Popconfirm
title={t('leaderList.deleteConfirm') || '确定要删除这个 Leader 吗?'} title={t('leaderList.deleteConfirm')}
description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) || `该 Leader 还有 ${leader.copyTradingCount} 个跟单关系,请先删除跟单关系` : undefined} description={leader.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: leader.copyTradingCount }) : undefined}
onConfirm={() => handleDelete(leader.id)} onConfirm={() => handleDelete(leader.id)}
okText={t('common.confirm') || '确定'} okText={t('common.confirm')}
cancelText={t('common.cancel') || '取消'} cancelText={t('common.cancel')}
> >
<Button <Button type="primary" danger size="small" icon={<DeleteOutlined />} style={{ flex: 1, minWidth: '80px', borderRadius: '6px' }}>
type="link" {t('common.delete')}
size="small"
danger
icon={<DeleteOutlined />}
style={{ flex: 1, minWidth: '80px' }}
>
{t('common.delete') || '删除'}
</Button> </Button>
</Popconfirm> </Popconfirm>
</div> </div>
@@ -284,22 +389,164 @@ const LeaderList: React.FC = () => {
)} )}
</div> </div>
) : ( ) : (
// 桌面端表格布局
<Table <Table
dataSource={leaders} dataSource={leaders}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={{ pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `${total}` }}
pageSize: 20, size="large"
showSizeChanger: true style={{ fontSize: '14px' }}
}}
/> />
)} )}
</Card> </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> </div>
) )
} }
export default LeaderList export default LeaderList
+16 -10
View File
@@ -292,32 +292,38 @@ export const apiService = {
/** /**
* 添加 Leader * 添加 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), apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
/** /**
* 更新 Leader * 更新 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), apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
/** /**
* 删除 Leader * 删除 Leader
*/ */
delete: (data: { leaderId: number }) => delete: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data), apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
/** /**
* 查询 Leader 列表 * 查询 Leader 列表
*/ */
list: (data: { category?: string } = {}) => list: (data: { category?: string } = {}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data), apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data),
/** /**
* 查询 Leader 详情 * 查询 Leader 详情
*/ */
detail: (data: { leaderId: number }) => detail: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data) apiClient.post<ApiResponse<any>>('/copy-trading/leaders/detail', data),
/**
* 查询 Leader 余额
*/
balance: (data: { leaderId: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
}, },
/** /**
+47
View File
@@ -79,6 +79,53 @@ export interface LeaderListResponse {
total: number 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 添加请求 * Leader 添加请求
*/ */