From 40474d258cc496362a27d1cc71aba6c6b25d82f4 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 31 Jan 2026 20:23:13 +0800 Subject: [PATCH] feat: improve UI/UX for exchange and model configuration - Redesign ExchangeConfigModal with icon card selection grid - Add step indicator for multi-step exchange configuration flow - Redesign ModelConfigModal with icon card selection pattern - Add KuCoin Futures exchange support with icon - Fix IPv4 detection for IP whitelist display --- api/server.go | 16 +- web/src/components/AITradersPage.tsx | 535 +++--- web/src/components/ExchangeIcons.tsx | 17 +- .../traders/ExchangeConfigModal.tsx | 1489 ++++++----------- 4 files changed, 809 insertions(+), 1248 deletions(-) diff --git a/api/server.go b/api/server.go index dfe4d7b3..8bcbfd00 100644 --- a/api/server.go +++ b/api/server.go @@ -256,13 +256,14 @@ func (s *Server) handleGetServerIP(c *gin.Context) { }) } -// getPublicIPFromAPI Get public IP via third-party API +// getPublicIPFromAPI Get public IP via third-party API (IPv4 only) func getPublicIPFromAPI() string { - // Try multiple public IP query services + // Try multiple public IP query services (IPv4-only endpoints) services := []string{ - "https://api.ipify.org?format=text", - "https://icanhazip.com", - "https://ifconfig.me", + "https://api4.ipify.org?format=text", // IPv4 only + "https://ipv4.icanhazip.com", // IPv4 only + "https://v4.ident.me", // IPv4 only + "https://api.ipify.org?format=text", // May return IPv4 or IPv6 } client := &http.Client{ @@ -284,8 +285,9 @@ func getPublicIPFromAPI() string { } ip := strings.TrimSpace(string(body[:n])) - // Verify if it's a valid IP address - if net.ParseIP(ip) != nil { + parsedIP := net.ParseIP(ip) + // Verify if it's a valid IPv4 address (not containing ":") + if parsedIP != nil && parsedIP.To4() != nil { return ip } } diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index f30f254c..2987ab1f 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -1384,6 +1384,99 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { ) } +// Step indicator component for Model Config +function ModelStepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) { + return ( +
+ {labels.map((label, index) => ( + +
+
+ {index < currentStep ? : index + 1} +
+ + {label} + +
+ {index < labels.length - 1 && ( +
+ )} + + ))} +
+ ) +} + +// Model card component +function ModelCard({ + model, + selected, + onClick, + configured, +}: { + model: AIModel + selected: boolean + onClick: () => void + configured?: boolean +}) { + return ( + + ) +} + // Model Configuration Modal Component function ModelConfigModal({ allModels, @@ -1407,17 +1500,16 @@ function ModelConfigModal({ onClose: () => void language: Language }) { + const [currentStep, setCurrentStep] = useState(editingModelId ? 1 : 0) const [selectedModelId, setSelectedModelId] = useState(editingModelId || '') const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [modelName, setModelName] = useState('') - // 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找 const selectedModel = editingModelId ? configuredModels?.find((m) => m.id === selectedModelId) : allModels?.find((m) => m.id === selectedModelId) - // 如果是编辑现有模型,初始化API Key、Base URL和Model Name useEffect(() => { if (editingModelId && selectedModel) { setApiKey(selectedModel.apiKey || '') @@ -1426,266 +1518,239 @@ function ModelConfigModal({ } }, [editingModelId, selectedModel]) + const handleSelectModel = (modelId: string) => { + setSelectedModelId(modelId) + setCurrentStep(1) + } + + const handleBack = () => { + if (editingModelId) { + onClose() + } else { + setCurrentStep(0) + setSelectedModelId('') + } + } + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!selectedModelId || !apiKey.trim()) return - - onSave( - selectedModelId, - apiKey.trim(), - baseUrl.trim() || undefined, - modelName.trim() || undefined - ) + onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined) } - // 可选择的模型列表(所有支持的模型) const availableModels = allModels || [] + const configuredIds = new Set(configuredModels?.map(m => m.id) || []) + const stepLabels = language === 'zh' ? ['选择模型', '配置 API'] : ['Select Model', 'Configure API'] return ( -
+
-
-

- {editingModelId - ? t('editAIModel', language) - : t('addAIModel', language)} -

- {editingModelId && ( - + )} +

+ {editingModelId ? t('editAIModel', language) : t('addAIModel', language)} +

+
+
+ {editingModelId && ( + + )} + - )} +
-
-
- {!editingModelId && ( -
- - -
- )} + {/* Step Indicator */} + {!editingModelId && ( +
+ +
+ )} - {selectedModel && ( -
-
-
- {getModelIcon(selectedModel.provider || selectedModel.id, { - width: 32, - height: 32, - }) || ( -
- {selectedModel.name[0]} -
- )} + {/* Content */} +
+ {/* Step 0: Select Model */} + {currentStep === 0 && !editingModelId && ( +
+
+ {language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'} +
+
+ {availableModels.map((model) => ( + handleSelectModel(model.id)} + configured={configuredIds.has(model.id)} + /> + ))} +
+
+ {language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'} +
+
+ )} + + {/* Step 1: Configure */} + {(currentStep === 1 || editingModelId) && selectedModel && ( + + {/* Selected Model Header */} +
+
+ {getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || ( + {selectedModel.name[0]} + )} +
+
+
+ {getShortName(selectedModel.name)}
-
-
- {getShortName(selectedModel.name)} -
-
- {selectedModel.provider} • {selectedModel.id} -
+
+ {selectedModel.provider} • {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id}
- {/* Default model info and API link */} {AI_PROVIDER_CONFIG[selectedModel.provider] && ( -
-
- {t('defaultModel', language)}: {AI_PROVIDER_CONFIG[selectedModel.provider].defaultModel} -
- - - {t('applyApiKey', language)} → {AI_PROVIDER_CONFIG[selectedModel.provider].apiName} - - {selectedModel.provider === 'kimi' && ( -
- ⚠️ {t('kimiApiNote', language)} -
- )} -
+ + + + {language === 'zh' ? '获取 API Key' : 'Get API Key'} + + )}
- )} - {selectedModel && ( - <> -
- - setApiKey(e.target.value)} - placeholder={t('enterAPIKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- -
- - setBaseUrl(e.target.value)} - placeholder={t('customBaseURLPlaceholder', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('leaveBlankForDefault', language)} + {/* Kimi Warning */} + {selectedModel.provider === 'kimi' && ( +
+
+ ⚠️ +
+ {t('kimiApiNote', language)} +
+ )} -
- - setModelName(e.target.value)} - placeholder={t('customModelNamePlaceholder', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('leaveBlankForDefaultModel', language)} -
+ {/* API Key */} +
+ + setApiKey(e.target.value)} + placeholder={t('enterAPIKey', language)} + className="w-full px-4 py-3 rounded-xl" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ + {/* Custom Base URL */} +
+ + setBaseUrl(e.target.value)} + placeholder={t('customBaseURLPlaceholder', language)} + className="w-full px-4 py-3 rounded-xl" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + /> +
+ {t('leaveBlankForDefault', language)}
+
-
+ + setModelName(e.target.value)} + placeholder={t('customModelNamePlaceholder', language)} + className="w-full px-4 py-3 rounded-xl" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + /> +
+ {t('leaveBlankForDefaultModel', language)} +
+
+ + {/* Info Box */} +
+
+ + {t('information', language)} +
+
+
• {t('modelConfigInfo1', language)}
+
• {t('modelConfigInfo2', language)}
+
• {t('modelConfigInfo3', language)}
+
+
+ + {/* Buttons */} +
+ +
- - )} -
- -
- - -
- + {t('saveConfig', language)} + + + + +
+ + )} +
) diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx index d97af73e..86494bfb 100644 --- a/web/src/components/ExchangeIcons.tsx +++ b/web/src/components/ExchangeIcons.tsx @@ -12,6 +12,7 @@ const ICON_PATHS: Record = { bybit: '/exchange-icons/bybit.png', okx: '/exchange-icons/okx.svg', bitget: '/exchange-icons/bitget.svg', + kucoin: '/exchange-icons/kucoin.svg', hyperliquid: '/exchange-icons/hyperliquid.png', aster: '/exchange-icons/aster.svg', lighter: '/exchange-icons/lighter.png', @@ -89,13 +90,15 @@ export const getExchangeIcon = ( ? 'okx' : lowerType.includes('bitget') ? 'bitget' - : lowerType.includes('hyperliquid') - ? 'hyperliquid' - : lowerType.includes('aster') - ? 'aster' - : lowerType.includes('lighter') - ? 'lighter' - : lowerType + : lowerType.includes('kucoin') + ? 'kucoin' + : lowerType.includes('hyperliquid') + ? 'hyperliquid' + : lowerType.includes('aster') + ? 'aster' + : lowerType.includes('lighter') + ? 'lighter' + : lowerType const iconProps = { width: props.width || 24, diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index d492ac7b..bceffa21 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -11,17 +11,21 @@ import { WebCryptoEnvironmentCheck, type WebCryptoCheckStatus, } from '../WebCryptoEnvironmentCheck' -import { BookOpen, Trash2, HelpCircle, ExternalLink, UserPlus } from 'lucide-react' +import { + BookOpen, Trash2, HelpCircle, ExternalLink, UserPlus, + Key, Shield, ChevronLeft, Check, Copy, ArrowRight +} from 'lucide-react' import { toast } from 'sonner' import { Tooltip } from './Tooltip' import { getShortName } from './utils' -// Supported exchange templates for creating new accounts +// Supported exchange templates const SUPPORTED_EXCHANGE_TEMPLATES = [ { exchange_type: 'binance', name: 'Binance Futures', type: 'cex' as const }, { exchange_type: 'bybit', name: 'Bybit Futures', type: 'cex' as const }, { exchange_type: 'okx', name: 'OKX Futures', type: 'cex' as const }, { exchange_type: 'bitget', name: 'Bitget Futures', type: 'cex' as const }, + { exchange_type: 'kucoin', name: 'KuCoin Futures', type: 'cex' as const }, { exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const }, { exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const }, { exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const }, @@ -31,12 +35,12 @@ interface ExchangeConfigModalProps { allExchanges: Exchange[] editingExchangeId: string | null onSave: ( - exchangeId: string | null, // null for creating new account + exchangeId: string | null, exchangeType: string, accountName: string, apiKey: string, secretKey?: string, - passphrase?: string, // OKX专用 + passphrase?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, @@ -52,6 +56,91 @@ interface ExchangeConfigModalProps { language: Language } +// Step indicator component +function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) { + return ( +
+ {labels.map((label, index) => ( + +
+
+ {index < currentStep ? : index + 1} +
+ + {label} + +
+ {index < labels.length - 1 && ( +
+ )} + + ))} +
+ ) +} + +// Exchange card component +function ExchangeCard({ + template, + selected, + onClick, + disabled, +}: { + template: typeof SUPPORTED_EXCHANGE_TEMPLATES[0] + selected: boolean + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + export function ExchangeConfigModal({ allExchanges, editingExchangeId, @@ -60,213 +149,156 @@ export function ExchangeConfigModal({ onClose, language, }: ExchangeConfigModalProps) { - // Selected exchange type for creating new accounts + // Step: 0 = select exchange, 1 = configure + const [currentStep, setCurrentStep] = useState(editingExchangeId ? 1 : 0) const [selectedExchangeType, setSelectedExchangeType] = useState('') const [apiKey, setApiKey] = useState('') const [secretKey, setSecretKey] = useState('') const [passphrase, setPassphrase] = useState('') const [testnet, setTestnet] = useState(false) const [showGuide, setShowGuide] = useState(false) - const [serverIP, setServerIP] = useState<{ - public_ip: string - message: string - } | null>(null) + const [serverIP, setServerIP] = useState<{ public_ip: string; message: string } | null>(null) const [loadingIP, setLoadingIP] = useState(false) const [copiedIP, setCopiedIP] = useState(false) - const [webCryptoStatus, setWebCryptoStatus] = - useState('idle') - - // 币安配置指南展开状态 + const [webCryptoStatus, setWebCryptoStatus] = useState('idle') const [showBinanceGuide, setShowBinanceGuide] = useState(false) - // Aster 特定字段 + // Aster fields const [asterUser, setAsterUser] = useState('') const [asterSigner, setAsterSigner] = useState('') const [asterPrivateKey, setAsterPrivateKey] = useState('') - // Hyperliquid 特定字段 + // Hyperliquid fields const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('') - // LIGHTER 特定字段 + // Lighter fields const [lighterWalletAddr, setLighterWalletAddr] = useState('') const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('') const [lighterApiKeyIndex, setLighterApiKeyIndex] = useState(0) - // 安全输入状态 - const [secureInputTarget, setSecureInputTarget] = useState< - null | 'hyperliquid' | 'aster' | 'lighter' - >(null) - - // 保存中状态 + // Other state + const [secureInputTarget, setSecureInputTarget] = useState(null) const [isSaving, setIsSaving] = useState(false) - - // 账户名称 const [accountName, setAccountName] = useState('') - // 获取当前编辑的交易所信息或模板 - // For editing: find the existing account by id (UUID) - // For creating: use the selected exchange template const selectedExchange = editingExchangeId ? allExchanges?.find((e) => e.id === editingExchangeId) : null - // Get the exchange template for displaying UI fields const selectedTemplate = editingExchangeId ? SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchange?.exchange_type) : SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchangeType) - // Get the current exchange type (from existing account or selected template) const currentExchangeType = editingExchangeId ? selectedExchange?.exchange_type : selectedExchangeType - // 交易所注册链接配置 const exchangeRegistrationLinks: Record = { binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true }, okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true }, bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true }, bitget: { url: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172', hasReferral: true }, + kucoin: { url: 'https://www.kucoin.com/r/broker/CXEV7XKK', hasReferral: true }, hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true }, aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true }, lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true }, } - // 如果是编辑现有交易所,初始化表单数据 + // Initialize form when editing useEffect(() => { if (editingExchangeId && selectedExchange) { setAccountName(selectedExchange.account_name || '') setApiKey(selectedExchange.apiKey || '') setSecretKey(selectedExchange.secretKey || '') - setPassphrase('') // Don't load existing passphrase for security + setPassphrase('') setTestnet(selectedExchange.testnet || false) - - // Aster 字段 setAsterUser(selectedExchange.asterUser || '') setAsterSigner(selectedExchange.asterSigner || '') - setAsterPrivateKey('') // Don't load existing private key for security - - // Hyperliquid 字段 + setAsterPrivateKey('') setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') - - // LIGHTER 字段 setLighterWalletAddr(selectedExchange.lighterWalletAddr || '') - setLighterApiKeyPrivateKey('') // Don't load existing API key for security + setLighterApiKeyPrivateKey('') setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0) } }, [editingExchangeId, selectedExchange]) - // 加载服务器IP(当选择binance时) + // Load server IP for Binance useEffect(() => { if (currentExchangeType === 'binance' && !serverIP) { setLoadingIP(true) - api - .getServerIP() - .then((data) => { - setServerIP(data) - }) - .catch((err) => { - console.error('Failed to load server IP:', err) - }) - .finally(() => { - setLoadingIP(false) - }) + api.getServerIP() + .then((data) => setServerIP(data)) + .catch((err) => console.error('Failed to load server IP:', err)) + .finally(() => setLoadingIP(false)) } - }, [currentExchangeType]) + }, [currentExchangeType, serverIP]) const handleCopyIP = async (ip: string) => { try { - // 优先使用现代 Clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { + if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(ip) setCopiedIP(true) setTimeout(() => setCopiedIP(false), 2000) toast.success(t('ipCopied', language)) } else { - // 降级方案: 使用传统的 execCommand 方法 const textArea = document.createElement('textarea') textArea.value = ip textArea.style.position = 'fixed' textArea.style.left = '-999999px' - textArea.style.top = '-999999px' document.body.appendChild(textArea) - textArea.focus() textArea.select() - - try { - const successful = document.execCommand('copy') - if (successful) { - setCopiedIP(true) - setTimeout(() => setCopiedIP(false), 2000) - toast.success(t('ipCopied', language)) - } else { - throw new Error('复制命令执行失败') - } - } finally { - document.body.removeChild(textArea) - } + document.execCommand('copy') + document.body.removeChild(textArea) + setCopiedIP(true) + setTimeout(() => setCopiedIP(false), 2000) + toast.success(t('ipCopied', language)) } - } catch (err) { - console.error('复制失败:', err) - // 显示错误提示 - toast.error( - t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址` - ) + } catch { + toast.error(t('copyIPFailed', language) || `复制失败: ${ip}`) } } - // 安全输入处理函数 const secureInputContextLabel = - secureInputTarget === 'aster' - ? t('asterExchangeName', language) - : secureInputTarget === 'hyperliquid' - ? t('hyperliquidExchangeName', language) + secureInputTarget === 'aster' ? t('asterExchangeName', language) + : secureInputTarget === 'hyperliquid' ? t('hyperliquidExchangeName', language) : undefined - const handleSecureInputCancel = () => { - setSecureInputTarget(null) - } - - const handleSecureInputComplete = ({ - value, - obfuscationLog, - }: TwoStageKeyModalResult) => { + const handleSecureInputComplete = ({ value }: TwoStageKeyModalResult) => { const trimmed = value.trim() - if (secureInputTarget === 'hyperliquid') { - setApiKey(trimmed) - } - if (secureInputTarget === 'aster') { - setAsterPrivateKey(trimmed) - } + if (secureInputTarget === 'hyperliquid') setApiKey(trimmed) + if (secureInputTarget === 'aster') setAsterPrivateKey(trimmed) if (secureInputTarget === 'lighter') { setLighterApiKeyPrivateKey(trimmed) toast.success(t('lighterApiKeyImported', language)) } - // 仅在开发环境输出调试信息 - if (import.meta.env.DEV) { - console.log('Secure input obfuscation log:', obfuscationLog) - } setSecureInputTarget(null) } - // 掩盖敏感数据显示 const maskSecret = (secret: string) => { if (!secret || secret.length === 0) return '' if (secret.length <= 8) return '*'.repeat(secret.length) - return ( - secret.slice(0, 4) + - '*'.repeat(Math.max(secret.length - 8, 4)) + - secret.slice(-4) - ) + return secret.slice(0, 4) + '*'.repeat(Math.max(secret.length - 8, 4)) + secret.slice(-4) + } + + const handleSelectExchange = (exchangeType: string) => { + setSelectedExchangeType(exchangeType) + setCurrentStep(1) + } + + const handleBack = () => { + if (editingExchangeId) { + onClose() + } else { + setCurrentStep(0) + setSelectedExchangeType('') + } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (isSaving) return - - // For creating, we need the exchange type if (!editingExchangeId && !selectedExchangeType) return - // Validate account name const trimmedAccountName = accountName.trim() if (!trimmedAccountName) { toast.error(language === 'zh' ? '请输入账户名称' : 'Please enter account name') @@ -278,65 +310,22 @@ export function ExchangeConfigModal({ setIsSaving(true) try { - // 根据交易所类型验证不同字段 - if (currentExchangeType === 'binance') { + if (currentExchangeType === 'binance' || currentExchangeType === 'bybit') { if (!apiKey.trim() || !secretKey.trim()) return await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet) - } else if (currentExchangeType === 'okx') { - if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return - await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) - } else if (currentExchangeType === 'bitget') { + } else if (currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin') { if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) } else if (currentExchangeType === 'hyperliquid') { - if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 - await onSave( - exchangeId, - exchangeType, - trimmedAccountName, - apiKey.trim(), - '', - '', - testnet, - hyperliquidWalletAddr.trim() - ) + if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return + await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), '', '', testnet, hyperliquidWalletAddr.trim()) } else if (currentExchangeType === 'aster') { - if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) - return - await onSave( - exchangeId, - exchangeType, - trimmedAccountName, - '', - '', - '', - testnet, - undefined, - asterUser.trim(), - asterSigner.trim(), - asterPrivateKey.trim() - ) + if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return + await onSave(exchangeId, exchangeType, trimmedAccountName, '', '', '', testnet, undefined, asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim()) } else if (currentExchangeType === 'lighter') { if (!lighterWalletAddr.trim() || !lighterApiKeyPrivateKey.trim()) return - await onSave( - exchangeId, - exchangeType, - trimmedAccountName, - '', // apiKey not used for Lighter - '', - '', - testnet, - undefined, // hyperliquidWalletAddr - undefined, // asterUser - undefined, // asterSigner - undefined, // asterPrivateKey - lighterWalletAddr.trim(), - '', // lighterPrivateKey (L1) no longer needed - lighterApiKeyPrivateKey.trim(), - lighterApiKeyIndex - ) + await onSave(exchangeId, exchangeType, trimmedAccountName, '', '', '', testnet, undefined, undefined, undefined, undefined, lighterWalletAddr.trim(), '', lighterApiKeyPrivateKey.trim(), lighterApiKeyIndex) } else { - // 默认情况(其他CEX交易所) if (!apiKey.trim() || !secretKey.trim()) return await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet) } @@ -345,34 +334,35 @@ export function ExchangeConfigModal({ } } + const stepLabels = language === 'zh' ? ['选择交易所', '配置账户'] : ['Select Exchange', 'Configure'] + const cexExchanges = SUPPORTED_EXCHANGE_TEMPLATES.filter(t => t.type === 'cex') + const dexExchanges = SUPPORTED_EXCHANGE_TEMPLATES.filter(t => t.type === 'dex') + return ( -
+
-
-

- {editingExchangeId - ? t('editExchange', language) - : t('addExchange', language)} -

+ {/* Header */} +
+
+ {currentStep > 0 && !editingExchangeId && ( + + )} +

+ {editingExchangeId ? t('editExchange', language) : t('addExchange', language)} +

+
- {currentExchangeType === 'binance' && ( + {currentExchangeType === 'binance' && currentStep === 1 && ( )} +
-
-
- {!editingExchangeId && ( -
-
-
- {t('environmentSteps.checkTitle', language)} -
- + {/* Step Indicator */} + {!editingExchangeId && ( +
+ +
+ )} + + {/* Content */} +
+ {/* Step 0: Select Exchange */} + {currentStep === 0 && !editingExchangeId && ( +
+ {/* WebCrypto Check */} +
+
+ + {t('environmentSteps.checkTitle', language)}
-
-
- {t('environmentSteps.selectTitle', language)} + +
+ + {/* Exchange Grid */} +
+
+ {language === 'zh' ? '选择您的交易所' : 'Choose Your Exchange'} +
+ + {/* CEX */} +
+
+ {language === 'zh' ? '中心化交易所 (CEX)' : 'Centralized Exchanges'}
- +
+
+ + {/* DEX */} +
+
+ {language === 'zh' ? '去中心化交易所 (DEX)' : 'Decentralized Exchanges'} +
+
+ {dexExchanges.map((template) => ( + handleSelectExchange(template.exchange_type)} + disabled={webCryptoStatus !== 'secure' && webCryptoStatus !== 'disabled'} + /> + ))} +
- )} +
+ )} - {selectedTemplate && ( -
-
-
- {getExchangeIcon(selectedTemplate.exchange_type, { - width: 32, - height: 32, - })} + {/* Step 1: Configure */} + {(currentStep === 1 || editingExchangeId) && selectedTemplate && ( + + {/* Selected Exchange Header */} +
+ {getExchangeIcon(selectedTemplate.exchange_type, { width: 48, height: 48 })} +
+
+ {getShortName(selectedTemplate.name)}
-
-
- {getShortName(selectedTemplate.name)} - {editingExchangeId && selectedExchange?.account_name && ( - - - {selectedExchange.account_name} - - )} -
-
- {selectedTemplate.type.toUpperCase()} •{' '} - {selectedTemplate.exchange_type} -
+
+ {selectedTemplate.type.toUpperCase()} • {selectedTemplate.exchange_type}
- - {/* 账户名称输入 */} -
- - setAccountName(e.target.value)} - placeholder={language === 'zh' ? '例如:主账户、套利账户' : 'e.g., Main Account, Arbitrage Account'} - className="w-full px-3 py-2 rounded" - style={{ - background: '#1E2329', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {language === 'zh' - ? '为此账户设置一个易于识别的名称,以便区分同一交易所的多个账户' - : 'Set an easily recognizable name for this account to distinguish multiple accounts on the same exchange'} -
-
- - {/* 注册链接 */} -
- - - {language === 'zh' ? '还没有交易所账号?点击注册' : "No exchange account? Register here"} + + + {language === 'zh' ? '注册' : 'Register'} + + {exchangeRegistrationLinks[currentExchangeType || '']?.hasReferral && ( + + {language === 'zh' ? '优惠' : 'Bonus'} - {exchangeRegistrationLinks[currentExchangeType || '']?.hasReferral && ( - - {language === 'zh' ? '折扣优惠' : 'Discount'} - - )} -
- + )}
- )} - {selectedTemplate && ( - <> - {/* Binance/Bybit/OKX/Bitget 的输入字段 */} - {(currentExchangeType === 'binance' || - currentExchangeType === 'bybit' || - currentExchangeType === 'okx' || - currentExchangeType === 'bitget') && ( - <> - {/* 币安用户配置提示 (D1 方案) */} - {currentExchangeType === 'binance' && ( -
setShowBinanceGuide(!showBinanceGuide)} - > -
-
- ℹ️ - - 币安用户必读: - 使用「现货与合约交易」API,不要用「统一账户 - API」 - -
- - {showBinanceGuide ? '▲' : '▼'} - -
+ {/* Account Name */} +
+ + setAccountName(e.target.value)} + placeholder={language === 'zh' ? '例如:主账户、套利账户' : 'e.g., Main Account'} + className="w-full px-4 py-3 rounded-xl text-base" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
- {/* 展开的详细说明 */} - {showBinanceGuide && ( -
e.stopPropagation()} - > -

- 原因:统一账户 API - 权限结构不同,会导致订单提交失败 -

- -

- 正确配置步骤: -

-
    -
  1. - 登录币安 → 个人中心 →{' '} - API 管理 -
  2. -
  3. - 创建 API → 选择「 - 系统生成的 API 密钥」 -
  4. -
  5. - 勾选「现货与合约交易」( - - 不选统一账户 - - ) -
  6. -
  7. - IP 限制选「无限制 - 」或添加服务器 IP -
  8. -
- -

- 💡 多资产模式用户注意: - 如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。 -

- - - 📖 查看币安官方教程 ↗ - -
- )} + {/* CEX Fields */} + {(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin') && ( + <> + {currentExchangeType === 'binance' && ( +
setShowBinanceGuide(!showBinanceGuide)} + > +
+
+ ℹ️ + + {language === 'zh' ? '币安用户必读:使用「现货与合约交易」API' : 'Use "Spot & Futures Trading" API'} +
- )} - -
- - setApiKey(e.target.value)} - placeholder={t('enterAPIKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> + {showBinanceGuide ? '▲' : '▼'}
- -
- - setSecretKey(e.target.value)} - placeholder={t('enterSecretKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- - {(currentExchangeType === 'okx' || currentExchangeType === 'bitget') && ( - )} - - {/* Binance 白名单IP提示 */} - {currentExchangeType === 'binance' && ( -
-
- {t('whitelistIP', language)} -
-
- {t('whitelistIPDesc', language)} -
- - {loadingIP ? ( -
- {t('loadingServerIP', language)} -
- ) : serverIP && serverIP.public_ip ? ( -
- - {serverIP.public_ip} - - -
- ) : null} -
- )} - +
)} - {/* Aster 交易所的字段 */} - {currentExchangeType === 'aster' && ( - <> - {/* API Pro 代理钱包说明 banner */} -
-
- - 🔐 - -
-
- {t('asterApiProTitle', language)} -
-
- {t('asterApiProDesc', language)} -
-
-
-
+
+ + setApiKey(e.target.value)} + placeholder={t('enterAPIKey', language)} + className="w-full px-4 py-3 rounded-xl" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
- {/* 主钱包地址 */} -
- - setAsterUser(e.target.value)} - placeholder={t('enterAsterUser', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('asterUserDesc', language)} -
-
+
+ + setSecretKey(e.target.value)} + placeholder={t('enterSecretKey', language)} + className="w-full px-4 py-3 rounded-xl" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
- {/* API Pro 代理钱包地址 */} -
- - setAsterSigner(e.target.value)} - placeholder={t('enterAsterSigner', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('asterSignerDesc', language)} -
-
- - {/* API Pro 代理钱包私钥 */} -
-