diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt index aa9466b..05220ff 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -34,6 +34,9 @@ data class CopyTradingCreateRequest( val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选) val pushFailedOrders: Boolean? = null // 推送失败订单(可选) @@ -65,6 +68,9 @@ data class CopyTradingUpdateRequest( val maxSpread: String? = null, val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选,但提供时必须非空) val pushFailedOrders: Boolean? = null // 推送失败订单(可选) @@ -133,6 +139,9 @@ data class CopyTradingDto( val maxSpread: String?, val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用 + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 // 新增配置字段 val configName: String? = null, // 配置名(可选) val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt index 0fa1922..ec2aa46 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -84,6 +84,13 @@ data class CopyTrading( @Column(name = "max_price", precision = 20, scale = 8) val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价 + // 最大仓位配置 + @Column(name = "max_position_value", precision = 20, scale = 8) + val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用 + + @Column(name = "max_position_count") + val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用 + // 新增配置字段 @Column(name = "config_name", length = 255) val configName: String? = null, // 配置名(可选) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt index e3eb97d..3a73ff0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt @@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.util.multi import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import com.wrbug.polymarketbot.service.common.PolymarketClobService +import com.wrbug.polymarketbot.service.accounts.AccountService import org.springframework.stereotype.Service import java.math.BigDecimal @@ -16,7 +17,8 @@ import java.math.BigDecimal */ @Service class CopyTradingFilterService( - private val clobService: PolymarketClobService + private val clobService: PolymarketClobService, + private val accountService: AccountService ) { private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java) @@ -26,12 +28,16 @@ class CopyTradingFilterService( * @param copyTrading 跟单配置 * @param tokenId token ID(用于获取订单簿) * @param tradePrice Leader 交易价格,用于价格区间检查 + * @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查 + * @param marketId 市场ID,用于仓位检查(按市场过滤仓位) * @return 过滤结果 */ suspend fun checkFilters( copyTrading: CopyTrading, tokenId: String, - tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查 + tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查 + copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查 + marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位) ): FilterResult { // 1. 价格区间检查(如果配置了价格区间) if (tradePrice != null) { @@ -76,6 +82,14 @@ class CopyTradingFilterService( } } + // 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID) + if (copyOrderAmount != null && marketId != null) { + val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId) + if (!positionCheck.isPassed) { + return positionCheck + } + } + return FilterResult.passed(orderbook) } @@ -184,5 +198,78 @@ class CopyTradingFilterService( return FilterResult.passed() } + + /** + * 检查仓位限制(按市场检查) + * @param copyTrading 跟单配置 + * @param copyOrderAmount 跟单金额(USDC) + * @param marketId 市场ID,用于过滤该市场的仓位 + * @return 过滤结果 + */ + private suspend fun checkPositionLimits( + copyTrading: CopyTrading, + copyOrderAmount: BigDecimal, + marketId: String + ): FilterResult { + // 如果未配置仓位限制,直接通过 + if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) { + return FilterResult.passed() + } + + try { + // 获取账户的所有仓位信息 + val positionsResult = accountService.getAllPositions() + if (positionsResult.isFailure) { + logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}") + // 如果获取仓位失败,为了安全起见,不通过检查 + return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查") + } + + val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空") + + // 过滤出当前账户且该市场的仓位 + val marketPositions = positions.currentPositions.filter { + it.accountId == copyTrading.accountId && it.marketId == marketId + } + + // 检查最大仓位金额(如果配置了) + if (copyTrading.maxPositionValue != null) { + // 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue) + val currentPositionValue = marketPositions.sumOf { position -> + position.currentValue.toSafeBigDecimal() + } + + // 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额 + val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount) + + if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) { + return FilterResult.maxPositionValueFailed( + "超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC" + ) + } + } + + // 检查最大仓位数量(如果配置了) + if (copyTrading.maxPositionCount != null) { + // 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位) + val currentPositionCount = marketPositions.size + + // 检查:该市场的当前仓位数量 <= 最大仓位数量 + // 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位 + // 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制 + if (currentPositionCount >= copyTrading.maxPositionCount) { + return FilterResult.maxPositionCountFailed( + "超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}" + ) + } + } + + return FilterResult.passed() + } catch (e: Exception) { + logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e) + // 如果检查异常,为了安全起见,不通过检查 + return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}") + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt index 2ed420f..f337e5f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt @@ -86,7 +86,9 @@ class CopyTradingService( minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread, minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice, - maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice + maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice, + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(), + maxPositionCount = request.maxPositionCount ) } else { // 手动输入(所有字段必须提供) @@ -112,7 +114,9 @@ class CopyTradingService( minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), maxSpread = request.maxSpread?.toSafeBigDecimal(), minPrice = request.minPrice?.toSafeBigDecimal(), - maxPrice = request.maxPrice?.toSafeBigDecimal() + maxPrice = request.maxPrice?.toSafeBigDecimal(), + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(), + maxPositionCount = request.maxPositionCount ) } @@ -139,6 +143,8 @@ class CopyTradingService( maxSpread = config.maxSpread, minPrice = config.minPrice, maxPrice = config.maxPrice, + maxPositionValue = config.maxPositionValue, + maxPositionCount = config.maxPositionCount, configName = configName, pushFailedOrders = request.pushFailedOrders ?: false ) @@ -204,6 +210,8 @@ class CopyTradingService( maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread, minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice, + maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue, + maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount, configName = configName, pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders, updatedAt = System.currentTimeMillis() @@ -409,6 +417,8 @@ class CopyTradingService( maxSpread = copyTrading.maxSpread?.toPlainString(), minPrice = copyTrading.minPrice?.toPlainString(), maxPrice = copyTrading.maxPrice?.toPlainString(), + maxPositionValue = copyTrading.maxPositionValue?.toPlainString(), + maxPositionCount = copyTrading.maxPositionCount, configName = copyTrading.configName, pushFailedOrders = copyTrading.pushFailedOrders, createdAt = copyTrading.createdAt, @@ -437,6 +447,8 @@ class CopyTradingService( val minOrderDepth: BigDecimal?, val maxSpread: BigDecimal?, val minPrice: BigDecimal?, - val maxPrice: BigDecimal? + val maxPrice: BigDecimal?, + val maxPositionValue: BigDecimal?, + val maxPositionCount: Int? ) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt index c4041e8..6d210a8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt @@ -17,7 +17,11 @@ enum class FilterStatus { /** 失败:价差过大 */ FAILED_SPREAD, /** 失败:订单深度不足 */ - FAILED_ORDER_DEPTH + FAILED_ORDER_DEPTH, + /** 失败:超过最大仓位金额 */ + FAILED_MAX_POSITION_VALUE, + /** 失败:超过最大仓位数量 */ + FAILED_MAX_POSITION_COUNT } /** @@ -73,6 +77,18 @@ data class FilterResult( reason = reason, orderbook = orderbook ) + + /** 超过最大仓位金额 */ + fun maxPositionValueFailed(reason: String) = FilterResult( + status = FilterStatus.FAILED_MAX_POSITION_VALUE, + reason = reason + ) + + /** 超过最大仓位数量 */ + fun maxPositionCountFailed(reason: String) = FilterResult( + status = FilterStatus.FAILED_MAX_POSITION_COUNT, + reason = reason + ) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index fd33965..db67019 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -241,11 +241,30 @@ open class CopyOrderTrackingService( } val tokenId = tokenIdResult.getOrNull() ?: continue + // 先计算跟单金额(用于仓位检查) + // 注意:这里先计算金额,即使后续被过滤也会记录 + val tradePrice = trade.price.toSafeBigDecimal() + val buyQuantity = try { + calculateBuyQuantity(trade, copyTrading) + } catch (e: Exception) { + logger.warn("计算买入数量失败: ${e.message}", e) + continue + } + + // 计算跟单金额(USDC)= 买入数量 × 价格 + val copyOrderAmount = buyQuantity.multi(tradePrice) + // 过滤条件检查(在计算订单参数之前) // 传入 Leader 交易价格,用于价格区间检查 + // 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位) // 订单簿只请求一次,返回给后续逻辑使用 - val tradePrice = trade.price.toSafeBigDecimal() - val filterResult = filterService.checkFilters(copyTrading, tokenId, tradePrice = tradePrice) + val filterResult = filterService.checkFilters( + copyTrading, + tokenId, + tradePrice = tradePrice, + copyOrderAmount = copyOrderAmount, + marketId = trade.market + ) val orderbook = filterResult.orderbook // 获取订单簿(如果需要) if (!filterResult.isPassed) { logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterResult.reason}") @@ -338,9 +357,8 @@ open class CopyOrderTrackingService( continue } - // 计算买入数量 - val buyQuantity = calculateBuyQuantity(trade, copyTrading) - + // 买入数量已在过滤检查前计算,这里直接使用 + // 如果数量为0或负数,跳过 if (buyQuantity.lte(BigDecimal.ZERO)) { logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}") continue @@ -1328,6 +1346,8 @@ open class CopyOrderTrackingService( FilterStatus.FAILED_ORDERBOOK_EMPTY -> "ORDERBOOK_EMPTY" FilterStatus.FAILED_SPREAD -> "SPREAD" FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH" + FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE" + FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT" } } diff --git a/backend/src/main/resources/db/migration/V11__add_max_position_config.sql b/backend/src/main/resources/db/migration/V11__add_max_position_config.sql new file mode 100644 index 0000000..f953963 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_max_position_config.sql @@ -0,0 +1,9 @@ +-- ============================================ +-- V11: 添加最大仓位配置字段 +-- 在 copy_trading 表中添加最大仓位金额和最大仓位数量配置 +-- ============================================ + +ALTER TABLE copy_trading +ADD COLUMN max_position_value DECIMAL(20, 8) NULL COMMENT '最大仓位金额(USDC),NULL表示不启用' AFTER max_price, +ADD COLUMN max_position_count INT NULL COMMENT '最大仓位数量,NULL表示不启用' AFTER max_position_value; + diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx new file mode 100644 index 0000000..a762001 --- /dev/null +++ b/frontend/src/components/AccountImportForm.tsx @@ -0,0 +1,342 @@ +import { useState } from 'react' +import { Form, Input, Button, Radio, Space, Alert, message } from 'antd' +import { useTranslation } from 'react-i18next' +import { useAccountStore } from '../store/accountStore' +import { + getAddressFromPrivateKey, + getAddressFromMnemonic, + getPrivateKeyFromMnemonic, + isValidWalletAddress, + isValidPrivateKey, + isValidMnemonic +} from '../utils' +import { useMediaQuery } from 'react-responsive' + +type ImportType = 'privateKey' | 'mnemonic' + +interface AccountImportFormProps { + form: any + onSuccess?: (accountId: number) => void + onCancel?: () => void + showAlert?: boolean + showCancelButton?: boolean +} + +const AccountImportForm: React.FC = ({ + form, + onSuccess, + onCancel, + showAlert = true, + showCancelButton = true +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { importAccount, loading } = useAccountStore() + const [importType, setImportType] = useState('privateKey') + const [derivedAddress, setDerivedAddress] = useState('') + const [addressError, setAddressError] = useState('') + + // 当私钥输入时,自动推导地址 + const handlePrivateKeyChange = (e: React.ChangeEvent) => { + const privateKey = e.target.value.trim() + if (!privateKey) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证私钥格式 + if (!isValidPrivateKey(privateKey)) { + setAddressError(t('accountImport.privateKeyInvalid')) + setDerivedAddress('') + return + } + + try { + const address = getAddressFromPrivateKey(privateKey) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || t('accountImport.addressError')) + setDerivedAddress('') + } + } + + // 当助记词输入时,自动推导地址 + const handleMnemonicChange = (e: React.ChangeEvent) => { + const mnemonic = e.target.value.trim() + if (!mnemonic) { + setDerivedAddress('') + setAddressError('') + return + } + + // 验证助记词格式 + if (!isValidMnemonic(mnemonic)) { + setAddressError(t('accountImport.mnemonicInvalid')) + setDerivedAddress('') + return + } + + try { + const address = getAddressFromMnemonic(mnemonic, 0) + setDerivedAddress(address) + setAddressError('') + + // 自动填充钱包地址字段 + form.setFieldsValue({ walletAddress: address }) + } catch (error: any) { + setAddressError(error.message || t('accountImport.addressErrorMnemonic')) + setDerivedAddress('') + } + } + + const handleSubmit = async (values: any) => { + try { + let privateKey: string + let walletAddress: string + + if (importType === 'privateKey') { + // 私钥模式 + privateKey = values.privateKey + walletAddress = values.walletAddress + + // 验证推导的地址和输入的地址是否一致 + if (derivedAddress && walletAddress !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) + } + } else { + // 助记词模式 + if (!values.mnemonic) { + return Promise.reject(new Error(t('accountImport.mnemonicRequired'))) + } + + // 从助记词导出私钥和地址 + privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0) + const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0) + + // 如果用户手动输入了地址,验证是否与推导的地址一致 + if (values.walletAddress) { + if (values.walletAddress !== derivedAddressFromMnemonic) { + // 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址) + walletAddress = derivedAddressFromMnemonic + } else { + // 地址匹配,使用用户输入的地址 + walletAddress = values.walletAddress + } + } else { + // 如果用户没有输入地址,使用推导的地址 + walletAddress = derivedAddressFromMnemonic + } + } + + // 验证钱包地址格式 + if (!isValidWalletAddress(walletAddress)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + + await importAccount({ + privateKey: privateKey, + walletAddress: walletAddress, + accountName: values.accountName + }) + + // 等待store更新 + await new Promise(resolve => setTimeout(resolve, 100)) + + // 获取新添加的账户ID(通过API获取,因为store可能还没更新) + const { apiService } = await import('../services/api') + const accountsResponse = await apiService.accounts.list() + if (accountsResponse.data.code === 0 && accountsResponse.data.data) { + const newAccounts = accountsResponse.data.data.list || [] + const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress) + if (newAccount && onSuccess) { + onSuccess(newAccount.id) + } else if (onSuccess) { + // 如果找不到账户,仍然调用onSuccess(可能在其他地方处理) + onSuccess(0) + } + } else if (onSuccess) { + // API调用失败,仍然调用onSuccess + onSuccess(0) + } + + return Promise.resolve() + } catch (error: any) { + return Promise.reject(error) + } + } + + return ( + <> + {showAlert && ( + + )} + +
+ + { + setImportType(e.target.value) + setDerivedAddress('') + setAddressError('') + form.setFieldsValue({ walletAddress: '' }) + }} + > + {t('accountImport.privateKey')} + {t('accountImport.mnemonic')} + + + + {importType === 'privateKey' ? ( + <> + { + if (!value) return Promise.resolve() + if (!isValidPrivateKey(value)) { + return Promise.reject(new Error(t('accountImport.privateKeyInvalid'))) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) + } + return Promise.resolve() + } + } + ]} + > + + + + ) : ( + <> + { + if (!value) return Promise.resolve() + if (!isValidMnemonic(value)) { + return Promise.reject(new Error(t('accountImport.mnemonicInvalid'))) + } + return Promise.resolve() + } + } + ]} + help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} + validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} + > + + + + { + if (!value) return Promise.resolve() + if (!isValidWalletAddress(value)) { + return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) + } + if (derivedAddress && value !== derivedAddress) { + return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic'))) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + {showCancelButton && onCancel && ( + + )} + + +
+ + ) +} + +export default AccountImportForm + diff --git a/frontend/src/components/LeaderAddForm.tsx b/frontend/src/components/LeaderAddForm.tsx new file mode 100644 index 0000000..d32db5a --- /dev/null +++ b/frontend/src/components/LeaderAddForm.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { Form, Input, Button, Space } from 'antd' +import { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' +import { isValidWalletAddress } from '../utils' + +interface LeaderAddFormProps { + form: any + onSuccess?: (leaderId: number) => void + onCancel?: () => void + showCancelButton?: boolean +} + +const LeaderAddForm: React.FC = ({ + form, + onSuccess, + onCancel, + showCancelButton = true +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (values: any) => { + setLoading(true) + try { + const response = await apiService.leaders.add({ + leaderAddress: values.leaderAddress.trim(), + leaderName: values.leaderName?.trim() || undefined, + remark: values.remark?.trim() || undefined, + website: values.website?.trim() || undefined + }) + + if (response.data.code === 0) { + if (response.data.data && onSuccess) { + onSuccess(response.data.data.id) + } + return Promise.resolve() + } else { + return Promise.reject(new Error(response.data.msg || t('leaderAdd.addFailed') || '添加 Leader 失败')) + } + } catch (error: any) { + return Promise.reject(error) + } finally { + setLoading(false) + } + } + + return ( +
+ { + if (!value) { + return Promise.reject(new Error(t('leaderAdd.leaderAddressRequired') || '请输入 Leader 钱包地址')) + } + if (!isValidWalletAddress(value.trim())) { + return Promise.reject(new Error(t('leaderAdd.leaderAddressInvalid') || '钱包地址格式不正确(必须是 0x 开头的 42 位地址)')) + } + return Promise.resolve() + } + } + ]} + tooltip={t('leaderAdd.leaderAddressTooltip') || '被跟单者的钱包地址,系统将监控该地址的交易并自动跟单'} + > + + + + + + + + + + + + + + + + + + + {showCancelButton && onCancel && ( + + )} + + +
+ ) +} + +export default LeaderAddForm + diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 684a579..d2addbe 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -65,7 +65,8 @@ "updateSuccess": "Account updated successfully", "updateFailed": "Failed to update account", "getDetailFailed": "Failed to get account details", - "accountIdRequired": "Account ID cannot be empty" + "accountIdRequired": "Account ID cannot be empty", + "proxyAddress": "Proxy Wallet Address" }, "message": { "success": "Operation successful", @@ -426,6 +427,7 @@ }, "leaderAdd": { "title": "Add Leader", + "back": "Back", "leaderAddress": "Leader Wallet Address", "leaderAddressRequired": "Please enter Leader wallet address", "leaderAddressInvalid": "Invalid wallet address format (must be a 42-character address starting with 0x)", @@ -667,6 +669,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "supportSell": "Support Sell", "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", "invalidNumber": "Please enter a valid number" @@ -732,6 +741,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -752,7 +768,11 @@ "invalidNumber": "Please enter a valid number", "fetchLeaderFailed": "Failed to get Leader list", "fetchTemplateFailed": "Failed to get template list", - "templateName": "Template Name" + "templateName": "Template Name", + "noAccounts": "No accounts", + "importAccount": "Import Account", + "noLeaders": "No Leaders", + "addLeader": "Add Leader" }, "copyTradingEdit": { "title": "Edit Copy Trading Config", @@ -807,6 +827,13 @@ "priceRangeTooltip": "Only copy orders where Leader's trade price is within the specified range. Leave empty to disable. Examples: Fill 0.11 and 0.89 means only copy orders with price between 0.11 and 0.89; Fill only max price 0.89 means only copy orders with price below 0.89; Fill only min price 0.11 means only copy orders with price above 0.11.", "minPricePlaceholder": "Min Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)", + "positionLimitFilter": "Max Position Limit", + "maxPositionValue": "Max Position Value (USDC)", + "maxPositionValueTooltip": "Limit the maximum position value for a single market. If the current position value + copy order amount exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionValuePlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxPositionCount": "Max Position Count", + "maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable", + "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -849,6 +876,8 @@ "orderbookError": "Orderbook Fetch Failed", "orderbookEmpty": "Orderbook Empty", "priceRange": "Price Range Mismatch", + "maxPositionValue": "Exceeds Max Position Value", + "maxPositionCount": "Exceeds Max Position Count", "unknown": "Unknown Reason" }, "noData": "No filtered orders" diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index bae7339..930b976 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -27,7 +27,10 @@ "total": "共", "items": "条", "prev": "上一页", - "next": "下一页" + "next": "下一页", + "success": "成功", + "failed": "失败", + "close": "关闭" }, "login": { "title": "登录", @@ -45,7 +48,13 @@ "success": "操作成功", "error": "操作失败", "loading": "加载中...", - "noData": "暂无数据" + "noData": "暂无数据", + "loginSuccess": "登录成功", + "loginFailed": "登录失败", + "createUserSuccess": "创建用户成功", + "createUserFailed": "创建用户失败", + "updatePasswordSuccess": "更新密码成功", + "updatePasswordFailed": "更新密码失败" }, "order": { "create": "创建订单", @@ -53,7 +62,47 @@ "cancel": "取消订单", "event": "订单事件", "buy": "买入", - "sell": "卖出" + "sell": "卖出", + "market": "市场", + "status": "状态", + "filled": "已成交", + "remaining": "剩余" + }, + "account": { + "title": "账户管理", + "list": "账户列表", + "detail": "账户详情", + "import": "导入账户", + "update": "更新账户", + "delete": "删除账户", + "accountId": "账户ID", + "accountName": "账户名称", + "accountNamePlaceholder": "账户名称(可选)", + "accountIdRequired": "账户ID不能为空", + "walletAddress": "钱包地址", + "proxyAddress": "代理钱包地址", + "apiCredentials": "API 凭证", + "apiKey": "API Key", + "apiSecret": "API Secret", + "apiPassphrase": "API Passphrase", + "balance": "余额", + "activeOrders": "活跃订单", + "completedOrders": "已完成订单", + "positionCount": "持仓数量", + "totalOrders": "总订单数", + "totalPnl": "总盈亏", + "statistics": "交易统计", + "fullConfig": "完整配置", + "partialConfig": "部分配置", + "notConfigured": "未配置", + "configured": "已配置", + "editTip": "编辑提示", + "editTipDesc": "API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。", + "leaveEmptyToNotModify": "留空表示不修改", + "getDetailFailed": "获取账户详情失败", + "updateSuccess": "更新账户成功", + "updateFailed": "更新账户失败", + "refreshBalance": "刷新余额" }, "accountList": { "title": "账户管理", @@ -156,10 +205,24 @@ "title": "Leader 管理", "leaderName": "Leader 名称", "leaderAddress": "钱包地址", + "walletAddress": "钱包地址", "category": "分类", + "all": "全部", + "copyTradingCount": "跟单关系数", + "createdAt": "创建时间", + "action": "操作", + "add": "添加", "addLeader": "添加 Leader", + "edit": "编辑", "editLeader": "编辑 Leader", - "deleteLeader": "删除 Leader" + "delete": "删除", + "deleteLeader": "删除 Leader", + "listFailed": "获取 Leader 列表失败", + "deleteSuccess": "删除 Leader 成功", + "deleteFailed": "删除 Leader 失败", + "deleteConfirm": "确定要删除这个 Leader 吗?", + "deleteConfirmDesc": "删除后无法恢复,请谨慎操作!", + "deleteConfirmOk": "确定删除" }, "menu": { "accounts": "账户管理", @@ -218,7 +281,9 @@ "saveSuccess": "保存配置成功", "saveFailed": "保存配置失败", "getFailed": "获取代理配置失败", - "latency": "延迟" + "latency": "延迟", + "hostInvalid": "请输入有效的主机地址", + "portInvalid": "端口必须在 1-65535 之间" }, "languageSettings": { "title": "语言设置", @@ -362,6 +427,7 @@ }, "leaderAdd": { "title": "添加 Leader", + "back": "返回", "leaderAddress": "Leader 钱包地址", "leaderAddressRequired": "请输入 Leader 钱包地址", "leaderAddressInvalid": "钱包地址格式不正确(必须是 0x 开头的 42 位地址)", @@ -603,6 +669,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", "invalidNumber": "请输入有效的数字" @@ -680,6 +753,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "create": "创建跟单配置", @@ -688,7 +768,11 @@ "invalidNumber": "请输入有效的数字", "fetchLeaderFailed": "获取 Leader 列表失败", "fetchTemplateFailed": "获取模板列表失败", - "templateName": "模板名称" + "templateName": "模板名称", + "noAccounts": "暂无账户", + "importAccount": "导入账户", + "noLeaders": "暂无 Leader", + "addLeader": "添加 Leader" }, "copyTradingEdit": { "title": "编辑跟单配置", @@ -755,6 +839,13 @@ "priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。", "minPricePlaceholder": "最低价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)", + "positionLimitFilter": "最大仓位限制", + "maxPositionValue": "最大仓位金额 (USDC)", + "maxPositionValueTooltip": "限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionValuePlaceholder": "例如:100(可选,不填写表示不启用)", + "maxPositionCount": "最大仓位数量", + "maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制", + "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "save": "保存", @@ -785,6 +876,8 @@ "orderbookError": "订单簿获取失败", "orderbookEmpty": "订单簿为空", "priceRange": "价格区间不符", + "maxPositionValue": "超过最大仓位金额", + "maxPositionCount": "超过最大仓位数量", "unknown": "未知原因" }, "noData": "暂无已过滤订单" diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 1bd3be7..a9ebba6 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -65,7 +65,8 @@ "updateSuccess": "更新賬戶成功", "updateFailed": "更新賬戶失敗", "getDetailFailed": "獲取賬戶詳情失敗", - "accountIdRequired": "賬戶ID不能為空" + "accountIdRequired": "賬戶ID不能為空", + "proxyAddress": "代理錢包地址" }, "message": { "success": "操作成功", @@ -426,6 +427,7 @@ }, "leaderAdd": { "title": "添加 Leader", + "back": "返回", "leaderAddress": "Leader 錢包地址", "leaderAddressRequired": "請輸入 Leader 錢包地址", "leaderAddressInvalid": "錢包地址格式不正確(必須是 0x 開頭的 42 位地址)", @@ -667,6 +669,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "supportSell": "跟單賣出", "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", "invalidNumber": "請輸入有效的數字" @@ -732,6 +741,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -752,7 +768,11 @@ "invalidNumber": "請輸入有效的數字", "fetchLeaderFailed": "獲取 Leader 列表失敗", "fetchTemplateFailed": "獲取模板列表失敗", - "templateName": "模板名稱" + "templateName": "模板名稱", + "noAccounts": "暫無賬戶", + "importAccount": "導入賬戶", + "noLeaders": "暫無 Leader", + "addLeader": "添加 Leader" }, "copyTradingEdit": { "title": "編輯跟單配置", @@ -807,6 +827,13 @@ "priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。", "minPricePlaceholder": "最低價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)", + "positionLimitFilter": "最大倉位限制", + "maxPositionValue": "最大倉位金額 (USDC)", + "maxPositionValueTooltip": "限制單個市場的最大倉位金額。如果該市場的當前倉位金額 + 跟單金額超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionValuePlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxPositionCount": "最大倉位數量", + "maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制", + "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -849,6 +876,8 @@ "orderbookError": "訂單簿獲取失敗", "orderbookEmpty": "訂單簿為空", "priceRange": "價格區間不符", + "maxPositionValue": "超過最大倉位金額", + "maxPositionCount": "超過最大倉位數量", "unknown": "未知原因" }, "noData": "暫無已過濾訂單" diff --git a/frontend/src/pages/AccountImport.tsx b/frontend/src/pages/AccountImport.tsx index 6551903..4c0a93a 100644 --- a/frontend/src/pages/AccountImport.tsx +++ b/frontend/src/pages/AccountImport.tsx @@ -1,150 +1,20 @@ -import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert } from 'antd' +import { Card, Form, Button, Typography } from 'antd' import { ArrowLeftOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -import { useAccountStore } from '../store/accountStore' -import { - getAddressFromPrivateKey, - getAddressFromMnemonic, - getPrivateKeyFromMnemonic, - isValidWalletAddress, - isValidPrivateKey, - isValidMnemonic -} from '../utils' -import { useMediaQuery } from 'react-responsive' +import { message } from 'antd' +import AccountImportForm from '../components/AccountImportForm' const { Title } = Typography -type ImportType = 'privateKey' | 'mnemonic' - const AccountImport: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() - const isMobile = useMediaQuery({ maxWidth: 768 }) - const { importAccount, loading } = useAccountStore() const [form] = Form.useForm() - const [importType, setImportType] = useState('privateKey') - const [derivedAddress, setDerivedAddress] = useState('') - const [addressError, setAddressError] = useState('') - // 当私钥输入时,自动推导地址 - const handlePrivateKeyChange = (e: React.ChangeEvent) => { - const privateKey = e.target.value.trim() - if (!privateKey) { - setDerivedAddress('') - setAddressError('') - return - } - - // 验证私钥格式 - if (!isValidPrivateKey(privateKey)) { - setAddressError(t('accountImport.privateKeyInvalid')) - setDerivedAddress('') - return - } - - try { - const address = getAddressFromPrivateKey(privateKey) - setDerivedAddress(address) - setAddressError('') - - // 自动填充钱包地址字段 - form.setFieldsValue({ walletAddress: address }) - } catch (error: any) { - setAddressError(error.message || t('accountImport.addressError')) - setDerivedAddress('') - } - } - - // 当助记词输入时,自动推导地址 - const handleMnemonicChange = (e: React.ChangeEvent) => { - const mnemonic = e.target.value.trim() - if (!mnemonic) { - setDerivedAddress('') - setAddressError('') - return - } - - // 验证助记词格式 - if (!isValidMnemonic(mnemonic)) { - setAddressError(t('accountImport.mnemonicInvalid')) - setDerivedAddress('') - return - } - - try { - const address = getAddressFromMnemonic(mnemonic, 0) - setDerivedAddress(address) - setAddressError('') - - // 自动填充钱包地址字段 - form.setFieldsValue({ walletAddress: address }) - } catch (error: any) { - setAddressError(error.message || t('accountImport.addressErrorMnemonic')) - setDerivedAddress('') - } - } - - const handleSubmit = async (values: any) => { - try { - let privateKey: string - let walletAddress: string - - if (importType === 'privateKey') { - // 私钥模式 - privateKey = values.privateKey - walletAddress = values.walletAddress - - // 验证推导的地址和输入的地址是否一致 - if (derivedAddress && walletAddress !== derivedAddress) { - message.error(t('accountImport.walletAddressMismatch')) - return - } - } else { - // 助记词模式 - if (!values.mnemonic) { - message.error(t('accountImport.mnemonicRequired')) - return - } - - // 从助记词导出私钥和地址 - privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0) - const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0) - - // 如果用户手动输入了地址,验证是否与推导的地址一致 - if (values.walletAddress) { - if (values.walletAddress !== derivedAddressFromMnemonic) { - // 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址) - message.warning(`${t('accountImport.walletAddressMismatchMnemonic')}: ${derivedAddressFromMnemonic}`) - walletAddress = derivedAddressFromMnemonic - } else { - // 地址匹配,使用用户输入的地址 - walletAddress = values.walletAddress - } - } else { - // 如果用户没有输入地址,使用推导的地址 - walletAddress = derivedAddressFromMnemonic - } - } - - // 验证钱包地址格式 - if (!isValidWalletAddress(walletAddress)) { - message.error(t('accountImport.walletAddressInvalid')) - return - } - - await importAccount({ - privateKey: privateKey, - walletAddress: walletAddress, - accountName: values.accountName - }) - - message.success(t('accountImport.importSuccess')) - navigate('/accounts') - } catch (error: any) { - message.error(error.message || t('accountImport.importFailed')) - } + const handleSuccess = async (accountId?: number) => { + message.success(t('accountImport.importSuccess')) + navigate('/accounts') } return ( @@ -161,165 +31,13 @@ const AccountImport: React.FC = () => { - - -
- - { - setImportType(e.target.value) - setDerivedAddress('') - setAddressError('') - form.setFieldsValue({ walletAddress: '' }) - }} - > - {t('accountImport.privateKey')} - {t('accountImport.mnemonic')} - - - - {importType === 'privateKey' ? ( - <> - { - if (!value) return Promise.resolve() - if (!isValidPrivateKey(value)) { - return Promise.reject(new Error(t('accountImport.privateKeyInvalid'))) - } - return Promise.resolve() - } - } - ]} - help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} - validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} - > - - - - { - if (!value) return Promise.resolve() - if (!isValidWalletAddress(value)) { - return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) - } - if (derivedAddress && value !== derivedAddress) { - return Promise.reject(new Error(t('accountImport.walletAddressMismatch'))) - } - return Promise.resolve() - } - } - ]} - > - - - - ) : ( - <> - { - if (!value) return Promise.resolve() - if (!isValidMnemonic(value)) { - return Promise.reject(new Error(t('accountImport.mnemonicInvalid'))) - } - return Promise.resolve() - } - } - ]} - help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')} - validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''} - > - - - - { - if (!value) return Promise.resolve() - if (!isValidWalletAddress(value)) { - return Promise.reject(new Error(t('accountImport.walletAddressInvalid'))) - } - if (derivedAddress && value !== derivedAddress) { - return Promise.reject(new Error(t('accountImport.walletAddressMismatchMnemonic'))) - } - return Promise.resolve() - } - } - ]} - > - - - - )} - - - - - - - - - - - - -
+ onSuccess={handleSuccess} + onCancel={() => navigate('/accounts')} + showAlert={true} + showCancelButton={true} + />
) diff --git a/frontend/src/pages/CopyTradingAdd.tsx b/frontend/src/pages/CopyTradingAdd.tsx index e10f8ab..8a7dfe0 100644 --- a/frontend/src/pages/CopyTradingAdd.tsx +++ b/frontend/src/pages/CopyTradingAdd.tsx @@ -1,12 +1,15 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Modal, Table, Select, Divider, Input } from 'antd' -import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined } from '@ant-design/icons' +import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined, PlusOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import { useAccountStore } from '../store/accountStore' import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../types' import { formatUSDC } from '../utils' import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import AccountImportForm from '../components/AccountImportForm' +import LeaderAddForm from '../components/LeaderAddForm' const { Title } = Typography const { Option } = Select @@ -14,6 +17,7 @@ const { Option } = Select const CopyTradingAdd: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) const { accounts, fetchAccounts } = useAccountStore() const [form] = Form.useForm() const [loading, setLoading] = useState(false) @@ -22,6 +26,14 @@ const CopyTradingAdd: React.FC = () => { const [templateModalVisible, setTemplateModalVisible] = useState(false) const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + // 导入账户modal相关状态 + const [accountImportModalVisible, setAccountImportModalVisible] = useState(false) + const [accountImportForm] = Form.useForm() + + // 添加leader modal相关状态 + const [leaderAddModalVisible, setLeaderAddModalVisible] = useState(false) + const [leaderAddForm] = Form.useForm() + // 生成默认配置名 const generateDefaultConfigName = (): string => { const now = new Date() @@ -85,7 +97,9 @@ const CopyTradingAdd: React.FC = () => { minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, - maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined + maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined, + maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined, + maxPositionCount: (template as any).maxPositionCount }) setCopyMode(template.copyMode) setTemplateModalVisible(false) @@ -96,6 +110,36 @@ const CopyTradingAdd: React.FC = () => { setCopyMode(mode) } + // 处理导入账户成功 + const handleAccountImportSuccess = async (accountId: number) => { + message.success(t('accountImport.importSuccess')) + + // 刷新账户列表 + await fetchAccounts() + + // 自动选择新添加的账户 + form.setFieldsValue({ accountId }) + + // 关闭modal并重置表单 + setAccountImportModalVisible(false) + accountImportForm.resetFields() + } + + // 处理添加leader成功 + const handleLeaderAddSuccess = async (leaderId: number) => { + message.success(t('leaderAdd.addSuccess') || '添加 Leader 成功') + + // 刷新leader列表 + await fetchLeaders() + + // 自动选择新添加的leader + form.setFieldsValue({ leaderId }) + + // 关闭modal并重置表单 + setLeaderAddModalVisible(false) + leaderAddForm.resetFields() + } + const handleSubmit = async (values: any) => { // 前端校验 if (values.copyMode === 'FIXED') { @@ -134,6 +178,8 @@ const CopyTradingAdd: React.FC = () => { maxSpread: values.maxSpread?.toString(), minPrice: values.minPrice?.toString(), maxPrice: values.maxPrice?.toString(), + maxPositionValue: values.maxPositionValue?.toString(), + maxPositionCount: values.maxPositionCount, configName: values.configName?.trim(), pushFailedOrders: values.pushFailedOrders ?? false } @@ -209,7 +255,24 @@ const CopyTradingAdd: React.FC = () => { name="accountId" rules={[{ required: true, message: t('copyTradingAdd.walletRequired') || '请选择钱包' }]} > - +
{t('copyTradingAdd.noAccounts') || '暂无账户'}
+ + + ) : null + } + > {accounts.map(account => (