diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaders/LeaderController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaders/LeaderController.kt index 1bce8d3..d0b087b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaders/LeaderController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaders/LeaderController.kt @@ -140,7 +140,7 @@ class LeaderController( if (request.leaderId <= 0) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource)) } - + val result = leaderService.getLeaderDetail(request.leaderId) result.fold( onSuccess = { leader -> @@ -159,6 +159,36 @@ class LeaderController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource)) } } + + /** + * 查询被跟单者余额 + */ + @PostMapping("/balance") + fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity> { + 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)) + } + } } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index 2ae6ff0..5ecb923 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -93,6 +93,16 @@ data class AccountListResponse( val total: Long ) +/** + * 钱包余额响应(通用类,用于 Account 和 Leader) + */ +data class WalletBalanceResponse( + val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额) + val positionBalance: String, // 仓位余额(持仓总价值) + val totalBalance: String, // 总余额 = 可用余额 + 仓位余额 + val positions: List = emptyList() +) + /** * 账户余额响应 */ @@ -108,6 +118,7 @@ data class AccountBalanceResponse( */ data class PositionDto( val marketId: String, + val title: String?, // 市场名称 val side: String, // YES 或 NO val quantity: String, val avgPrice: String, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt index a0794a0..8d91ae1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt @@ -36,6 +36,13 @@ data class LeaderListRequest( val category: String? = null // sports 或 crypto ) +/** + * Leader 余额请求 + */ +data class LeaderBalanceRequest( + val leaderId: Long // LeaderID(必需) +) + /** * Leader 信息响应 */ @@ -61,3 +68,16 @@ data class LeaderListResponse( 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 = emptyList() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index a401b4a..fc20724 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -278,7 +278,7 @@ class AccountService( if (accountId == null) { return Result.failure(IllegalArgumentException("账户ID不能为空")) } - + val account = accountRepository.findById(accountId).orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) @@ -288,68 +288,19 @@ class AccountService( return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址")) } - // 查询 USDC 余额和持仓信息 + // 使用通用方法查询余额 val balanceResult = runBlocking { - try { - // 查询持仓信息(用于返回持仓列表) - // 使用代理地址查询持仓(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 - } + blockchainService.getWalletBalance(account.proxyAddress) } - Result.success(balanceResult) + balanceResult.map { walletBalance: WalletBalanceResponse -> + AccountBalanceResponse( + availableBalance = walletBalance.availableBalance, + positionBalance = walletBalance.positionBalance, + totalBalance = walletBalance.totalBalance, + positions = walletBalance.positions + ) + } } catch (e: Exception) { logger.error("查询账户余额失败", e) Result.failure(e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index e0d669d..6445c34 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -8,9 +8,12 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PositionResponse import com.wrbug.polymarketbot.api.ValueResponse 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.RetrofitFactory import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import com.wrbug.polymarketbot.service.system.RelayClientService import com.wrbug.polymarketbot.service.system.RpcNodeService @@ -255,7 +258,6 @@ class BlockchainService( return Result.failure(IllegalArgumentException("代理地址不能为空")) } - // 使用 RPC 查询 USDC 余额(使用代理地址) val balance = queryUsdcBalanceViaRpc(proxyAddress) Result.success(balance) @@ -264,6 +266,82 @@ class BlockchainService( Result.failure(e) } } + + /** + * 查询钱包余额(通用方法) + * 用于 Account 和 Leader 的余额查询 + * @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产) + * @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表 + */ + suspend fun getWalletBalance(walletAddress: String): Result { + 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 余额 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt index 025791b..4ab057d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt @@ -5,10 +5,12 @@ import com.wrbug.polymarketbot.entity.Leader import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CopyTradingRepository import com.wrbug.polymarketbot.repository.LeaderRepository +import com.wrbug.polymarketbot.service.common.BlockchainService import com.wrbug.polymarketbot.util.CategoryValidator import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import kotlinx.coroutines.runBlocking /** * Leader 管理服务 @@ -17,7 +19,8 @@ import org.springframework.transaction.annotation.Transactional class LeaderService( private val leaderRepository: LeaderRepository, private val accountRepository: AccountRepository, - private val copyTradingRepository: CopyTradingRepository + private val copyTradingRepository: CopyTradingRepository, + private val blockchainService: BlockchainService ) { private val logger = LoggerFactory.getLogger(LeaderService::class.java) @@ -176,7 +179,7 @@ class LeaderService( return try { val leader = leaderRepository.findById(leaderId).orElse(null) ?: return Result.failure(IllegalArgumentException("Leader 不存在")) - + val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId) Result.success(toDto(leader, copyTradingCount)) } catch (e: Exception) { @@ -184,6 +187,40 @@ class LeaderService( Result.failure(e) } } + + /** + * 查询 Leader 余额 + * 使用代理地址查询 USDC 余额和持仓信息 + */ + fun getLeaderBalance(leaderId: Long): Result { + 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 diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index c88b786..fa99942 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -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", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 4675fbf..0c7df29 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -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": "用户名", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index c0ad6d2..375a0d3 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -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": "用戶名", diff --git a/frontend/src/pages/LeaderList.tsx b/frontend/src/pages/LeaderList.tsx index a4affd7..2cce2bd 100644 --- a/frontend/src/pages/LeaderList.tsx +++ b/frontend/src/pages/LeaderList.tsx @@ -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([]) const [loading, setLoading] = useState(false) - + const [balanceMap, setBalanceMap] = useState>({}) + const [balanceLoading, setBalanceLoading] = useState>({}) + + // 详情 Modal + const [detailModalVisible, setDetailModalVisible] = useState(false) + const [detailLeader, setDetailLeader] = useState(null) + const [detailBalance, setDetailBalance] = useState(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 - + const displayText = isMobile && title.length > 20 ? `${title.slice(0, 20)}...` : title + return {displayText} + } + }, + { + title: t('leaderDetail.side'), + dataIndex: 'side', + key: 'side', + render: (side: string) => { + const color = side === 'YES' ? 'green' : 'red' + return {side} + } + }, + { + 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 - + } else { + const numPnl = parseFloat(pnl) + const color = numPnl > 0 ? '#52c41a' : '#ff4d4f' + return {formatUSDC(pnl)} + } + const numPnl = parseFloat(pnl) + const color = numPnl > 0 ? '#52c41a' : '#ff4d4f' + return {formatUSDC(pnl)} + } + } + ] + } + 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) => ( - - {isMobile ? `${address.slice(0, 6)}...${address.slice(-4)}` : address} - + width: 150, + render: (text: string, record: Leader) => ( + + {text || `Leader ${record.id}`} + {record.leaderAddress} + ) }, { - title: t('leaderList.remark') || '备注', + title: t('leaderList.remark'), dataIndex: 'remark', key: 'remark', - render: (remark: string | undefined) => remark ? ( - - {remark} - - ) : - - }, - { - title: t('leaderList.copyTradingCount') || '跟单关系数', - dataIndex: 'copyTradingCount', - key: 'copyTradingCount', - render: (count: number) => {count} - }, - { - 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 - + return {remark} } }, { - title: t('common.actions') || '操作', + title: t('leaderDetail.totalBalance'), + key: 'balance', + width: 150, + render: (_: any, record: Leader) => { + const balance = balanceMap[record.id] + if (!balance) return + const displayText = balance.total === '-' ? '-' : `${formatUSDC(balance.total)} USDC` + return {displayText} + } + }, + { + title: t('leaderList.copyTradingCount'), + dataIndex: 'copyTradingCount', + key: 'copyTradingCount', + width: 100, + render: (count: number) => {count} + }, + { + title: t('common.actions'), key: 'action', - width: isMobile ? 150 : 200, + width: isMobile ? 180 : 250, + fixed: 'right', render: (_: any, record: Leader) => ( + {record.website && ( - )} - 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')} > ) } ] - + return (
-
-

{t('leaderList.title') || 'Leader 管理'}

-
- - + + {isMobile ? ( - // 移动端卡片布局
{loading ? (
) : leaders.length === 0 ? ( - + ) : ( { - 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 ( - - {/* Leader 名称和地址 */} +
-
+
{leader.leaderName || `Leader ${leader.id}`}
-
+
{leader.leaderAddress}
- - {/* 备注 */} - {leader.remark && ( -
- - {t('leaderList.remark') || '备注'}: - - - {leader.remark} - + + {balance && ( +
+
+ {t('leaderDetail.totalBalance')}: {balance.total === '-' ? '-' : `${formatUSDC(balance.total)} USDC`} +
+
+ {t('leaderDetail.availableBalance')}: {formatUSDC(balance.available)} | {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)} +
)} - + - - {/* 跟单关系数 */} -
- {t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`} + +
+ {leader.copyTradingCount} {t('leaderList.copyTradingCount')}
- - {/* 创建时间 */} -
- {t('leaderList.createdAt') || '创建时间'}: {formattedDate} -
- - {/* 操作按钮 */} + + {leader.remark && ( +
+ {t('leaderList.remark')}: + {leader.remark} +
+ )} +
+ {leader.website && ( - )} - 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')} > -
@@ -284,22 +389,164 @@ const LeaderList: React.FC = () => { )}
) : ( - // 桌面端表格布局 `共 ${total} 条` }} + size="large" + style={{ fontSize: '14px' }} /> )} + + {/* 详情 Modal */} + + + {t('leaderDetail.title')} + + } + open={detailModalVisible} + onCancel={() => setDetailModalVisible(false)} + footer={[ + + ]} + width={isMobile ? '95%' : 1000} + style={{ top: 20 }} + > + {!detailLeader ? ( +
+ +
+ ) : ( + <> + {/* 基本信息 */} + + + {t('leaderDetail.basicInfo')} + + } + bordered + column={isMobile ? 1 : 2} + size={isMobile ? 'small' : 'default'} + > + + {detailLeader.leaderName || `Leader ${detailLeader.id}`} + + + {detailLeader.leaderAddress} + + + {detailLeader.copyTradingCount || 0} + + + {detailLeader.remark || -} + + + {formatTimestamp(detailLeader.updatedAt)} + + + {detailLeader.website ? ( + + ) : -} + + + + + + {/* 余额信息 */} +
+ + + {t('leaderDetail.balanceInfo')} + + +
+ + {detailBalanceLoading && !detailBalance ? ( +
+ +
+ ) : detailBalance ? ( + <> + +
+ + formatUSDC(value?.toString() || '0')} + /> + + + + + formatUSDC(value?.toString() || '0')} + /> + + + + + formatUSDC(value?.toString() || '0')} + /> + + + + + {/* 持仓列表 */} + +
+ + {t('leaderDetail.positions')} + {detailBalance.positions?.length || 0} + +
+ + {detailBalance.positions && detailBalance.positions.length > 0 ? ( +
`${record.title}-${record.side}-${index}`} + pagination={{ pageSize: 10, showSizeChanger: !isMobile }} + scroll={{ x: isMobile ? 800 : 'auto' }} + size={isMobile ? 'small' : 'default'} + /> + ) : ( + + )} + + ) : ( + + )} + + )} + ) } export default LeaderList - diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6c263e8..6ed1900 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>('/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>('/copy-trading/leaders/update', data), - + /** * 删除 Leader */ - delete: (data: { leaderId: number }) => + delete: (data: { leaderId: number }) => apiClient.post>('/copy-trading/leaders/delete', data), - + /** * 查询 Leader 列表 */ - list: (data: { category?: string } = {}) => + list: (data: { category?: string } = {}) => apiClient.post>('/copy-trading/leaders/list', data), - + /** * 查询 Leader 详情 */ - detail: (data: { leaderId: number }) => - apiClient.post>('/copy-trading/leaders/detail', data) + detail: (data: { leaderId: number }) => + apiClient.post>('/copy-trading/leaders/detail', data), + + /** + * 查询 Leader 余额 + */ + balance: (data: { leaderId: number }) => + apiClient.post>('/copy-trading/leaders/balance', data) }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 96dcc21..9ed58ab 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 添加请求 */