feat: 优化跟单买入逻辑和错误处理

- 更新 Twitter 链接为 @polyhermes
- 简化订单创建失败日志,只保留 code 和 errorBody
- 修复 @Transactional 注解问题(类改为 open class)
- 改进唯一键冲突异常处理,支持并发场景
- 整理跟单买入和重试逻辑,使用常量配置
- 重试延迟从 1 秒调整为 3 秒
- 添加订单簿匹配检查,避免 FAK 订单失败
- 前端移除 Leader 管理的分类相关设置和显示
- 补充多语言翻译(AccountImport 和 CopyTradingList)
This commit is contained in:
WrBug
2025-12-11 02:58:50 +08:00
parent f8d7276654
commit 0625c62e36
13 changed files with 256 additions and 149 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ docker pull wrbug/polyhermes:v1.0.1
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@quant_tr
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
+2 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
> 🌐 **Language**: [English](README_EN.md) | 中文
@@ -414,7 +414,7 @@ cd frontend
## 🔗 相关链接
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com)
+2 -2
View File
@@ -1,7 +1,7 @@
# PolyHermes
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@quant__tr-blue?logo=twitter)](https://x.com/quant_tr)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
> 🌐 **Language**: English | [中文](README.md)
@@ -414,7 +414,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
## 🔗 Related Links
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/quant_tr)
- [Twitter](https://x.com/polyhermes)
- [Polymarket Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.polymarket.com)
@@ -10,6 +10,8 @@ import com.wrbug.polymarketbot.util.*
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.dao.DuplicateKeyException
import java.sql.SQLException
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
@@ -27,7 +29,7 @@ import java.math.BigDecimal
* 实际创建订单并记录跟踪信息
*/
@Service
class CopyOrderTrackingService(
open class CopyOrderTrackingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
@@ -50,6 +52,12 @@ class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 订单创建重试配置
companion object {
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
}
/**
* 解密账户私钥
@@ -144,21 +152,38 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(可能是 DataIntegrityViolationException、DuplicateKeyException 或 SQLException
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 再次检查确认状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return Result.success(Unit)
}
// 如果还是查询不到,记录警告但不抛出异常(可能是其他约束冲突)
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 不抛出异常,避免影响其他交易的处理
return Result.success(Unit)
}
return Result.success(Unit)
} else {
// 如果检查不到,说明可能是其他约束冲突,重新抛出异常
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,重新抛出
throw e
}
}
@@ -358,6 +383,35 @@ class CopyOrderTrackingService(
// 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
val orderbookForCheck = orderbook ?: run {
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (orderbookResult.isSuccess) {
orderbookResult.getOrNull()
} else {
null
}
}
// 检查是否有可匹配的卖单(asks)
if (orderbookForCheck != null) {
val bestAsk = orderbookForCheck.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestAsk == null) {
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
if (buyPrice.lt(bestAsk)) {
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
continue
}
}
// 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -383,7 +437,9 @@ class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
val createOrderResult = createOrderWithRetry(
clobApi = clobApi,
privateKey = decryptedPrivateKey,
@@ -397,8 +453,9 @@ class CopyOrderTrackingService(
tradeId = trade.id
)
// 处理订单创建失败
if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
@@ -407,16 +464,19 @@ class CopyOrderTrackingService(
finalBuyQuantity.toString(),
trade.id
)
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade(
leaderId = leaderId,
trade = trade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "BUY", // 订单方向是BUY
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg,
retryCount = 1 // 已重试
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次
)
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
@@ -427,7 +487,8 @@ class CopyOrderTrackingService(
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
val marketResponse =
gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
@@ -544,7 +605,7 @@ class CopyOrderTrackingService(
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
val leaderName = leader?.leaderName
@@ -648,8 +709,8 @@ class CopyOrderTrackingService(
* 卖出订单匹配
* 统一按比例计算,不区分RATIO或FIXED模式
* 实际创建卖出订单并记录匹配关系
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
*/
@Transactional
private suspend fun matchSellOrder(
copyTrading: CopyTrading,
leaderSellTrade: TradeResponse
@@ -872,8 +933,23 @@ class CopyOrderTrackingService(
/**
* 创建订单(带重试机制)
* 失败后重试一次,如果仍然失败则返回失败结果
* 注意:重试时会重新生成salt并重新签名,确保每次重试都是新的订单
*
* 重试策略:
* - 最多重试 MAX_RETRY_ATTEMPTS 次(首次尝试 + 重试)
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
* - 每次重试都重新生成salt并重新签名,确保签名唯一性
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥(用于签名)
* @param makerAddress 代理钱包地址
* @param tokenId Token ID
* @param side 订单方向(BUY/SELL
* @param price 价格
* @param size 数量
* @param owner API Key(用于owner字段)
* @param copyTradingId 跟单配置ID(用于日志)
* @param tradeId Leader 交易ID(用于日志)
* @return 成功返回订单ID,失败返回异常
*/
private suspend fun createOrderWithRetry(
clobApi: PolymarketClobApi,
@@ -883,16 +959,16 @@ class CopyOrderTrackingService(
side: String,
price: String,
size: String,
owner: String, // API Key,用于owner字段
owner: String,
copyTradingId: Long,
tradeId: String
): Result<String> {
var lastError: Exception? = null
// 最多重试2次(首次 + 1次重试)
for (attempt in 1..2) {
// 重试循环:最多重试 MAX_RETRY_ATTEMPTS 次
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
try {
// 每次重试都重新生成salt并重新签名
// 每次重试都重新生成salt并重新签名,确保签名唯一性
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = privateKey,
makerAddress = makerAddress,
@@ -906,76 +982,118 @@ class CopyOrderTrackingService(
expiration = "0"
)
// 构建订单请求(每次重试都使用新签名的订单)
// 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = owner, // API Key
owner = owner,
orderType = "FAK", // Fill-And-Kill
deferExec = false
)
// 调用 API 创建订单
val orderResponse = clobApi.createOrder(orderRequest)
// 检查 HTTP 响应状态
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
val errorBody = try {
orderResponse.errorBody()?.string()
} catch (e: Exception) {
null
}
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
// 检查业务响应状态
val response = orderResponse.body()!!
if (!response.success || response.orderId == null) {
val errorMsg =
"创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}"
val errorMsg = "errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg)
// 所有失败都记录详细日志
logger.error(errorMsg)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
// 成功
// 创建订单成功
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
return Result.success(response.orderId)
} catch (e: Exception) {
val errorMsg =
"调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}"
val errorMsg = "error=${e.message}"
lastError = Exception(errorMsg, e)
// 所有失败都记录详细日志(包括堆栈)
logger.error(errorMsg, e)
if (attempt < 2) {
delay(1000) // 重试前等待1秒
// 记录错误日志(包含堆栈)
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
continue
}
return Result.failure(lastError)
}
}
val finalError = lastError ?: Exception("创建订单失败:未知错误")
logger.error(
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId",
finalError
)
// 所有重试都失败
val finalError = lastError ?: Exception("error=未知错误")
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
return Result.failure(finalError)
}
/**
* 构建完整的错误信息(包括堆栈)
* 检查是否是唯一键冲突异常
*/
private fun isUniqueConstraintViolation(e: Exception): Boolean {
// 检查是否是 DataIntegrityViolationException 或 DuplicateKeyException
if (e is DataIntegrityViolationException || e is DuplicateKeyException) {
return true
}
// 检查是否是 SQLExceptionMySQL 错误码 1062 表示重复键)
var cause: Throwable? = e.cause
while (cause != null) {
if (cause is SQLException) {
val sqlException = cause as SQLException
// MySQL 错误码 1062 表示重复键(Duplicate entry
if (sqlException.errorCode == 1062 || sqlException.sqlState == "23000") {
return true
}
}
// 检查异常消息中是否包含唯一键冲突的关键字
val message = cause.message ?: ""
if (message.contains("Duplicate entry") ||
message.contains("uk_leader_trade") ||
message.contains("UNIQUE constraint")
) {
return true
}
cause = cause.cause
}
return false
}
/**
* 构建简化的错误信息(只保留 code 和 errorBody
*/
private fun buildFullErrorMessage(
exception: Throwable?,
@@ -985,35 +1103,29 @@ class CopyOrderTrackingService(
tradeId: String
): String {
if (exception == null) {
return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误"
return "code=未知, errorBody=null"
}
val errorMsg = StringBuilder()
errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId")
errorMsg.append(", error=${exception.message}")
val exceptionMessage = exception.message ?: ""
// 添加堆栈信息(限制长度,避免过长)
val stackTrace = exception.stackTraceToString()
val maxLength = 2000 // 限制错误信息最大长度为2000字符
if (stackTrace.length > maxLength) {
errorMsg.append(", stackTrace=${stackTrace.substring(0, maxLength)}...")
} else {
errorMsg.append(", stackTrace=$stackTrace")
}
// 从错误信息中提取 code 和 errorBody
val codePattern = Regex("code=([^,}]+)")
val errorBodyPattern = Regex("errorBody=([^,}]+)")
// 如果有 cause,也添加
exception.cause?.let { cause ->
errorMsg.append(", cause=${cause.message}")
}
val codeMatch = codePattern.find(exceptionMessage)
val errorBodyMatch = errorBodyPattern.find(exceptionMessage)
return errorMsg.toString()
val code = codeMatch?.groupValues?.get(1)?.trim() ?: "未知"
val errorBody = errorBodyMatch?.groupValues?.get(1)?.trim() ?: "null"
return "code=$code, errorBody=$errorBody"
}
/**
* 记录失败交易到数据库
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
*/
@Transactional
private fun recordFailedTrade(
private suspend fun recordFailedTrade(
leaderId: Long,
trade: TradeResponse,
copyTradingId: Long,
@@ -1064,21 +1176,35 @@ class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: DataIntegrityViolationException) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} catch (e: Exception) {
// 检查是否是唯一键冲突异常
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
}
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
}
}
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
+1 -1
View File
@@ -648,5 +648,5 @@ Vite uses `.env.production` file to inject environment variables during build. T
## Technical Support
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/quant_tr).
If you have any questions, please submit an Issue to [GitHub](https://github.com/WrBug/PolyHermes) or contact [Twitter](https://x.com/polyhermes).
+1 -1
View File
@@ -648,5 +648,5 @@ Vite 使用 `.env.production` 文件在构建时注入环境变量。构建脚
## 技术支持
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/quant_tr)。
如有问题,请提交 Issue 到 [GitHub](https://github.com/WrBug/PolyHermes) 或联系 [Twitter](https://x.com/polyhermes)。
+2 -2
View File
@@ -238,7 +238,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<GithubOutlined />
</a>
<a
href="https://x.com/quant_tr"
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
@@ -362,7 +362,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<GithubOutlined />
</a>
<a
href="https://x.com/quant_tr"
href="https://x.com/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
+2
View File
@@ -856,6 +856,8 @@
"copyTradingList": {
"title": "Copy Trading Config Management",
"addCopyTrading": "Add Copy Trading",
"configName": "Config Name",
"configNameNotProvided": "Not Provided",
"wallet": "Wallet",
"account": "Account",
"template": "Template",
+26 -5
View File
@@ -117,19 +117,40 @@
},
"accountImport": {
"title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥",
"mnemonic": "助记词",
"privateKeyLabel": "私钥",
"privateKeyPlaceholder": "请输入私钥(64位十六进制字符串,可选0x前缀)",
"privateKeyRequired": "请输入私钥",
"privateKeyPlaceholder": "请输入或粘贴私钥",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
"privateKeyInvalid": "私钥格式不正确(应为64位十六进制字符串)",
"walletAddress": "钱包地址",
"walletAddressPlaceholder": "钱包地址(将从私钥自动推导)",
"walletAddressRequired": "请输入钱包地址",
"walletAddressInvalid": "钱包地址格式不正确",
"walletAddressMismatch": "钱包地址与私钥不匹配",
"mnemonicLabel": "助记词",
"mnemonicPlaceholder": "请输入12或24个单词的助记词(用空格分隔)",
"mnemonicRequired": "请输入助记词",
"mnemonicInvalid": "助记词格式不正确(应为12或24个单词,用空格分隔)",
"walletAddressMismatchMnemonic": "钱包地址与助记词不匹配",
"accountName": "账户名称",
"accountNameRequired": "请输入账户名称",
"accountNamePlaceholder": "请输入账户名称",
"accountNamePlaceholder": "可选,用于标识账户",
"accountNameHelp": "用于标识账户,便于管理",
"privateKeyHelp": "私钥将加密存储,仅用于签名交易",
"submit": "导入",
"invalidPrivateKey": "无效的私钥",
"duplicateAccount": "账户已存在",
"importAccount": "导入账户",
"importSuccess": "导入账户成功",
"importFailed": "导入账户失败",
"invalidPrivateKey": "无效的私钥",
"duplicateAccount": "账户已存在"
"derivedAddress": "推导地址",
"addressError": "无法从私钥推导地址",
"addressErrorMnemonic": "无法从助记词推导地址"
},
"leader": {
"title": "Leader 管理",
+2
View File
@@ -856,6 +856,8 @@
"copyTradingList": {
"title": "跟單配置管理",
"addCopyTrading": "新增跟單",
"configName": "配置名",
"configNameNotProvided": "未提供",
"wallet": "錢包",
"account": "賬戶",
"template": "模板",
+2 -15
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Select, message, Typography, Space } from 'antd'
import { Card, Form, Input, Button, message, Typography, Space } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import { isValidWalletAddress } from '../utils'
const { Title } = Typography
const { Option } = Select
const LeaderAdd: React.FC = () => {
const { t } = useTranslation()
@@ -24,8 +23,7 @@ const LeaderAdd: React.FC = () => {
leaderAddress: values.leaderAddress.trim(),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
website: values.website?.trim() || undefined
})
if (response.data.code === 0) {
@@ -121,17 +119,6 @@ const LeaderAdd: React.FC = () => {
<Input placeholder={t('leaderAdd.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderAdd.category') || '分类筛选'}
name="category"
tooltip={t('leaderAdd.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderAdd.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button
+3 -17
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Form, Input, Button, Select, message, Typography, Space, Spin } from 'antd'
import { Card, Form, Input, Button, message, Typography, Space, Spin } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
@@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
import type { Leader } from '../types'
const { Title } = Typography
const { Option } = Select
const LeaderEdit: React.FC = () => {
const { t } = useTranslation()
@@ -38,8 +37,7 @@ const LeaderEdit: React.FC = () => {
form.setFieldsValue({
leaderName: leader.leaderName || '',
remark: leader.remark || '',
website: leader.website || '',
category: leader.category || undefined
website: leader.website || ''
})
} else {
message.error(response.data.msg || t('leaderEdit.fetchFailed') || '获取 Leader 详情失败')
@@ -65,8 +63,7 @@ const LeaderEdit: React.FC = () => {
leaderId: parseInt(leaderId),
leaderName: values.leaderName?.trim() || undefined,
remark: values.remark?.trim() || undefined,
website: values.website?.trim() || undefined,
category: values.category || undefined
website: values.website?.trim() || undefined
})
if (response.data.code === 0) {
@@ -145,17 +142,6 @@ const LeaderEdit: React.FC = () => {
<Input placeholder={t('leaderEdit.websitePlaceholder') || '可选,例如:https://example.com'} />
</Form.Item>
<Form.Item
label={t('leaderEdit.category') || '分类筛选'}
name="category"
tooltip={t('leaderEdit.categoryTooltip') || '仅跟单该分类的交易,不选择则跟单所有分类(sports 或 crypto'}
>
<Select placeholder={t('leaderEdit.categoryPlaceholder') || '选择分类(可选)'} allowClear>
<Option value="sports">Sports</Option>
<Option value="crypto">Crypto</Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button
+2 -19
View File
@@ -67,14 +67,6 @@ const LeaderList: React.FC = () => {
</span>
)
},
{
title: t('leaderList.category') || '分类',
dataIndex: 'category',
key: 'category',
render: (category: string | undefined) => category ? (
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
) : <Tag>{t('leaderList.all') || '全部'}</Tag>
},
{
title: t('leaderList.remark') || '备注',
dataIndex: 'remark',
@@ -252,18 +244,9 @@ const LeaderList: React.FC = () => {
<Divider style={{ margin: '12px 0' }} />
{/* 分类和跟单关系数 */}
{/* 跟单关系数 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
{leader.category ? (
<Tag color={leader.category === 'sports' ? 'blue' : 'green'}>
{leader.category}
</Tag>
) : (
<Tag>{t('leaderList.all') || '全部'}</Tag>
)}
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
</div>
<Tag>{t('leaderList.copyTradingRelations', { count: leader.copyTradingCount }) || `${leader.copyTradingCount} 个跟单关系`}</Tag>
</div>
{/* 创建时间 */}