diff --git a/.gitignore b/.gitignore index d28e72ae..05dd17f4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,25 +30,10 @@ Thumbs.db # 环境变量 .env config.json -<<<<<<< HEAD -config.db -.tool-versions -======= config.db* nofx.db configbak.json -# 生产配置 -nginx/ -certs/ -beta_codes.txt - -# 密钥文件 -keys/ -*.key -*.pem ->>>>>>> beta - # 决策日志 decision_logs/ coin_pool_cache/ @@ -59,9 +44,6 @@ web/node_modules/ node_modules/ web/dist/ web/.vite/ -<<<<<<< HEAD -web/yarn.lock -======= # ESLint 临时报告文件(调试时生成,不纳入版本控制) eslint-*.json @@ -140,4 +122,4 @@ dmypy.json # Pyre type checker .pyre/ ->>>>>>> beta +PR_DESCRIPTION.md diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx new file mode 100644 index 00000000..29dc286c --- /dev/null +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -0,0 +1,926 @@ +import React, { useState, useEffect } from 'react' +import type { Exchange } from '../../types' +import { t, type Language } from '../../i18n/translations' +import { api } from '../../lib/api' +import { getExchangeIcon } from '../ExchangeIcons' +import { + TwoStageKeyModal, + type TwoStageKeyModalResult, +} from '../TwoStageKeyModal' +import { + WebCryptoEnvironmentCheck, + type WebCryptoCheckStatus, +} from '../WebCryptoEnvironmentCheck' +import { BookOpen, Trash2, HelpCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Tooltip } from './Tooltip' +import { getShortName } from './utils' + +interface ExchangeConfigModalProps { + allExchanges: Exchange[] + editingExchangeId: string | null + onSave: ( + exchangeId: string, + apiKey: string, + secretKey?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string + ) => Promise + onDelete: (exchangeId: string) => void + onClose: () => void + language: Language +} + +export function ExchangeConfigModal({ + allExchanges, + editingExchangeId, + onSave, + onDelete, + onClose, + language, +}: ExchangeConfigModalProps) { + const [selectedExchangeId, setSelectedExchangeId] = useState( + editingExchangeId || '' + ) + 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 [loadingIP, setLoadingIP] = useState(false) + const [copiedIP, setCopiedIP] = useState(false) + const [webCryptoStatus, setWebCryptoStatus] = + useState('idle') + + // 币安配置指南展开状态 + const [showBinanceGuide, setShowBinanceGuide] = useState(false) + + // Aster 特定字段 + const [asterUser, setAsterUser] = useState('') + const [asterSigner, setAsterSigner] = useState('') + const [asterPrivateKey, setAsterPrivateKey] = useState('') + + // Hyperliquid 特定字段 + const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('') + + // 安全输入状态 + const [secureInputTarget, setSecureInputTarget] = useState< + null | 'hyperliquid' | 'aster' + >(null) + + // 获取当前编辑的交易所信息 + const selectedExchange = allExchanges?.find( + (e) => e.id === selectedExchangeId + ) + + // 如果是编辑现有交易所,初始化表单数据 + useEffect(() => { + if (editingExchangeId && selectedExchange) { + setApiKey(selectedExchange.apiKey || '') + setSecretKey(selectedExchange.secretKey || '') + setPassphrase('') // Don't load existing passphrase for security + setTestnet(selectedExchange.testnet || false) + + // Aster 字段 + setAsterUser(selectedExchange.asterUser || '') + setAsterSigner(selectedExchange.asterSigner || '') + setAsterPrivateKey('') // Don't load existing private key for security + + // Hyperliquid 字段 + setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') + } + }, [editingExchangeId, selectedExchange]) + + // 加载服务器IP(当选择binance时) + useEffect(() => { + if (selectedExchangeId === 'binance' && !serverIP) { + setLoadingIP(true) + api + .getServerIP() + .then((data) => { + setServerIP(data) + }) + .catch((err) => { + console.error('Failed to load server IP:', err) + }) + .finally(() => { + setLoadingIP(false) + }) + } + }, [selectedExchangeId]) + + const handleCopyIP = async (ip: string) => { + try { + // 优先使用现代 Clipboard API + if (navigator.clipboard && 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) + } + } + } catch (err) { + console.error('复制失败:', err) + // 显示错误提示 + toast.error( + t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址` + ) + } + } + + // 安全输入处理函数 + const secureInputContextLabel = + secureInputTarget === 'aster' + ? t('asterExchangeName', language) + : secureInputTarget === 'hyperliquid' + ? t('hyperliquidExchangeName', language) + : undefined + + const handleSecureInputCancel = () => { + setSecureInputTarget(null) + } + + const handleSecureInputComplete = ({ + value, + obfuscationLog, + }: TwoStageKeyModalResult) => { + const trimmed = value.trim() + if (secureInputTarget === 'hyperliquid') { + setApiKey(trimmed) + } + if (secureInputTarget === 'aster') { + setAsterPrivateKey(trimmed) + } + 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) + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!selectedExchangeId) return + + // 根据交易所类型验证不同字段 + if (selectedExchange?.id === 'binance') { + if (!apiKey.trim() || !secretKey.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } else if (selectedExchange?.id === 'hyperliquid') { + if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 + await onSave( + selectedExchangeId, + apiKey.trim(), + '', + testnet, + hyperliquidWalletAddr.trim() + ) + } else if (selectedExchange?.id === 'aster') { + if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) + return + await onSave( + selectedExchangeId, + '', + '', + testnet, + undefined, + asterUser.trim(), + asterSigner.trim(), + asterPrivateKey.trim() + ) + } else if (selectedExchange?.id === 'okx') { + if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } else { + // 默认情况(其他CEX交易所) + if (!apiKey.trim() || !secretKey.trim()) return + await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet) + } + } + + // 可选择的交易所列表(所有支持的交易所) + const availableExchanges = allExchanges || [] + + return ( +
+
+
+

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

+
+ {selectedExchange?.id === 'binance' && ( + + )} + {editingExchangeId && ( + + )} +
+
+ +
+
+ {!editingExchangeId && ( +
+
+
+ {t('environmentSteps.checkTitle', language)} +
+ +
+
+
+ {t('environmentSteps.selectTitle', language)} +
+ +
+
+ )} + + {selectedExchange && ( +
+
+
+ {getExchangeIcon(selectedExchange.id, { + width: 32, + height: 32, + })} +
+
+
+ {getShortName(selectedExchange.name)} +
+
+ {selectedExchange.type.toUpperCase()} •{' '} + {selectedExchange.id} +
+
+
+
+ )} + + {selectedExchange && ( + <> + {/* Binance 和其他 CEX 交易所的字段 */} + {(selectedExchange.id === 'binance' || + selectedExchange.type === 'cex') && + selectedExchange.id !== 'hyperliquid' && + selectedExchange.id !== 'aster' && ( + <> + {/* 币安用户配置提示 (D1 方案) */} + {selectedExchange.id === 'binance' && ( +
setShowBinanceGuide(!showBinanceGuide)} + > +
+
+ ℹ️ + + 币安用户必读: + 使用「现货与合约交易」API,不要用「统一账户 + API」 + +
+ + {showBinanceGuide ? '▲' : '▼'} + +
+ + {/* 展开的详细说明 */} + {showBinanceGuide && ( +
e.stopPropagation()} + > +

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

+ +

+ 正确配置步骤: +

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

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

+ + + 📖 查看币安官方教程 ↗ + +
+ )} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + {selectedExchange.id === 'okx' && ( +
+ + setPassphrase(e.target.value)} + placeholder={t('enterPassphrase', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ )} + + {/* Binance 白名单IP提示 */} + {selectedExchange.id === 'binance' && ( +
+
+ {t('whitelistIP', language)} +
+
+ {t('whitelistIPDesc', language)} +
+ + {loadingIP ? ( +
+ {t('loadingServerIP', language)} +
+ ) : serverIP && serverIP.public_ip ? ( +
+ + {serverIP.public_ip} + + +
+ ) : null} +
+ )} + + )} + + {/* Aster 交易所的字段 */} + {selectedExchange.id === 'aster' && ( + <> +
+ + setAsterUser(e.target.value)} + placeholder={t('enterUser', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setAsterSigner(e.target.value)} + placeholder={t('enterSigner', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ +
+ + setAsterPrivateKey(e.target.value)} + placeholder={t('enterPrivateKey', language)} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ + )} + + {/* Hyperliquid 交易所的字段 */} + {selectedExchange.id === 'hyperliquid' && ( + <> + {/* 安全提示 banner */} +
+
+ + 🔐 + +
+
+ {t('hyperliquidAgentWalletTitle', language)} +
+
+ {t('hyperliquidAgentWalletDesc', language)} +
+
+
+
+ + {/* Agent Private Key 字段 */} +
+ +
+
+ + + {apiKey && ( + + )} +
+ {apiKey && ( +
+ {t('secureInputHint', language)} +
+ )} +
+
+ {t('hyperliquidAgentPrivateKeyDesc', language)} +
+
+ + {/* Main Wallet Address 字段 */} +
+ + + setHyperliquidWalletAddr(e.target.value) + } + placeholder={t( + 'enterHyperliquidMainWalletAddress', + language + )} + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ {t('hyperliquidMainWalletAddressDesc', language)} +
+
+ + )} + + )} +
+ +
+ + +
+
+
+ + {/* Binance Setup Guide Modal */} + {showGuide && ( +
setShowGuide(false)} + > +
e.stopPropagation()} + > +
+

+ + {t('binanceSetupGuide', language)} +

+ +
+
+ {t('binanceSetupGuide', +
+
+
+ )} + + {/* Two Stage Key Modal */} + +
+ ) +} diff --git a/web/src/components/traders/ModelConfigModal.tsx b/web/src/components/traders/ModelConfigModal.tsx new file mode 100644 index 00000000..86297ba5 --- /dev/null +++ b/web/src/components/traders/ModelConfigModal.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect } from 'react' +import { Trash2 } from 'lucide-react' +import { t, type Language } from '../../i18n/translations' +import type { AIModel } from '../../types' +import { getModelIcon } from '../ModelIcons' +import { getShortName } from './utils' + +interface ModelConfigModalProps { + allModels: AIModel[] + configuredModels: AIModel[] + editingModelId: string | null + onSave: ( + modelId: string, + apiKey: string, + baseUrl?: string, + modelName?: string + ) => void + onDelete: (modelId: string) => void + onClose: () => void + language: Language +} + +export function ModelConfigModal({ + allModels, + configuredModels, + editingModelId, + onSave, + onDelete, + onClose, + language, +}: ModelConfigModalProps) { + 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 || '') + setBaseUrl(selectedModel.customApiUrl || '') + setModelName(selectedModel.customModelName || '') + } + }, [editingModelId, selectedModel]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!selectedModelId || !apiKey.trim()) return + + onSave( + selectedModelId, + apiKey.trim(), + baseUrl.trim() || undefined, + modelName.trim() || undefined + ) + } + + // 可选择的模型列表(所有支持的模型) + const availableModels = allModels || [] + + return ( +
+
+
+

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

+ {editingModelId && ( + + )} +
+ +
+
+ {!editingModelId && ( +
+ + +
+ )} + + {selectedModel && ( +
+
+
+ {getModelIcon(selectedModel.provider || selectedModel.id, { + width: 32, + height: 32, + }) || ( +
+ {selectedModel.name[0]} +
+ )} +
+
+
+ {getShortName(selectedModel.name)} +
+
+ {selectedModel.provider} • {selectedModel.id} +
+
+
+
+ )} + + {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)} +
+
+ +
+ + setModelName(e.target.value)} + placeholder="例如: deepseek-chat, qwen3-max, gpt-5" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ 留空使用默认模型名称 +
+
+ +
+
+ ℹ️ {t('information', language)} +
+
+
{t('modelConfigInfo1', language)}
+
{t('modelConfigInfo2', language)}
+
{t('modelConfigInfo3', language)}
+
+
+ + )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/traders/SignalSourceModal.tsx b/web/src/components/traders/SignalSourceModal.tsx new file mode 100644 index 00000000..7c0fe814 --- /dev/null +++ b/web/src/components/traders/SignalSourceModal.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { t, type Language } from '../../i18n/translations' + +interface SignalSourceModalProps { + coinPoolUrl: string + oiTopUrl: string + onSave: (coinPoolUrl: string, oiTopUrl: string) => void + onClose: () => void + language: Language +} + +export function SignalSourceModal({ + coinPoolUrl, + oiTopUrl, + onSave, + onClose, + language, +}: SignalSourceModalProps) { + const [coinPool, setCoinPool] = useState(coinPoolUrl || '') + const [oiTop, setOiTop] = useState(oiTopUrl || '') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave(coinPool.trim(), oiTop.trim()) + } + + return ( +
+
+

+ {t('signalSourceConfig', language)} +

+ +
+
+
+ + setCoinPool(e.target.value)} + placeholder="https://api.example.com/coinpool" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('coinPoolDescription', language)} +
+
+ +
+ + setOiTop(e.target.value)} + placeholder="https://api.example.com/oitop" + className="w-full px-3 py-2 rounded" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> +
+ {t('oiTopDescription', language)} +
+
+ +
+
+ ℹ️ {t('information', language)} +
+
+
{t('signalSourceInfo1', language)}
+
{t('signalSourceInfo2', language)}
+
{t('signalSourceInfo3', language)}
+
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/traders/Tooltip.tsx b/web/src/components/traders/Tooltip.tsx new file mode 100644 index 00000000..3b31b799 --- /dev/null +++ b/web/src/components/traders/Tooltip.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' + +interface TooltipProps { + content: string + children: React.ReactNode +} + +export function Tooltip({ content, children }: TooltipProps) { + const [show, setShow] = useState(false) + + return ( +
+
setShow(true)} + onMouseLeave={() => setShow(false)} + onClick={() => setShow(!show)} + > + {children} +
+ {show && ( +
+ {content} +
+
+ )} +
+ ) +} diff --git a/web/src/components/traders/index.ts b/web/src/components/traders/index.ts new file mode 100644 index 00000000..4c0528fc --- /dev/null +++ b/web/src/components/traders/index.ts @@ -0,0 +1,5 @@ +export { Tooltip } from './Tooltip' +export { SignalSourceModal } from './SignalSourceModal' +export { ModelConfigModal } from './ModelConfigModal' +export { ExchangeConfigModal } from './ExchangeConfigModal' +export { getModelDisplayName, getShortName } from './utils' diff --git a/web/src/components/traders/sections/AIModelsSection.tsx b/web/src/components/traders/sections/AIModelsSection.tsx new file mode 100644 index 00000000..059c8fa5 --- /dev/null +++ b/web/src/components/traders/sections/AIModelsSection.tsx @@ -0,0 +1,97 @@ +import { Brain } from 'lucide-react' +import { t, Language } from '../../../i18n/translations' +import { getModelIcon } from '../../ModelIcons' +import { getShortName } from '../utils' +import type { AIModel } from '../../../types' + +interface AIModelsSectionProps { + language: Language + configuredModels: AIModel[] + isModelInUse: (modelId: string) => boolean + onModelClick: (modelId: string) => void +} + +export function AIModelsSection({ + language, + configuredModels, + isModelInUse, + onModelClick, +}: AIModelsSectionProps) { + return ( +
+

+ + {t('aiModels', language)} +

+
+ {configuredModels.map((model) => { + const inUse = isModelInUse(model.id) + return ( +
onModelClick(model.id)} + > +
+
+ {getModelIcon(model.provider || model.id, { + width: 28, + height: 28, + }) || ( +
+ {getShortName(model.name)[0]} +
+ )} +
+
+
+ {getShortName(model.name)} +
+
+ {inUse + ? t('inUse', language) + : model.enabled + ? t('enabled', language) + : t('configured', language)} +
+
+
+
+
+ ) + })} + {configuredModels.length === 0 && ( +
+ +
+ {t('noModelsConfigured', language)} +
+
+ )} +
+
+ ) +} diff --git a/web/src/components/traders/sections/ExchangesSection.tsx b/web/src/components/traders/sections/ExchangesSection.tsx new file mode 100644 index 00000000..5c0235e2 --- /dev/null +++ b/web/src/components/traders/sections/ExchangesSection.tsx @@ -0,0 +1,87 @@ +import { Landmark } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' +import { getExchangeIcon } from '../../ExchangeIcons' +import { getShortName } from '../index' +import type { Exchange } from '../../../types' + +interface ExchangesSectionProps { + language: Language + configuredExchanges: Exchange[] + isExchangeInUse: (exchangeId: string) => boolean + onExchangeClick: (exchangeId: string) => void +} + +export function ExchangesSection({ + language, + configuredExchanges, + isExchangeInUse, + onExchangeClick, +}: ExchangesSectionProps) { + return ( +
+

+ + {t('exchanges', language)} +

+
+ {configuredExchanges.map((exchange) => { + const inUse = isExchangeInUse(exchange.id) + return ( +
onExchangeClick(exchange.id)} + > +
+
+ {getExchangeIcon(exchange.id, { width: 28, height: 28 })} +
+
+
+ {getShortName(exchange.name)} +
+
+ {exchange.type.toUpperCase()} •{' '} + {inUse + ? t('inUse', language) + : exchange.enabled + ? t('enabled', language) + : t('configured', language)} +
+
+
+
+
+ ) + })} + {configuredExchanges.length === 0 && ( +
+ +
+ {t('noExchangesConfigured', language)} +
+
+ )} +
+
+ ) +} diff --git a/web/src/components/traders/sections/PageHeader.tsx b/web/src/components/traders/sections/PageHeader.tsx new file mode 100644 index 00000000..4584766a --- /dev/null +++ b/web/src/components/traders/sections/PageHeader.tsx @@ -0,0 +1,117 @@ +import { Bot, Plus, Radio } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' + +interface PageHeaderProps { + language: Language + tradersCount: number + configuredModelsCount: number + configuredExchangesCount: number + onAddModel: () => void + onAddExchange: () => void + onConfigureSignalSource: () => void + onCreateTrader: () => void +} + +export function PageHeader({ + language, + tradersCount, + configuredModelsCount, + configuredExchangesCount, + onAddModel, + onAddExchange, + onConfigureSignalSource, + onCreateTrader, +}: PageHeaderProps) { + const canCreateTrader = + configuredModelsCount > 0 && configuredExchangesCount > 0 + + return ( +
+
+
+ +
+
+

+ {t('aiTraders', language)} + + {tradersCount} {t('active', language)} + +

+

+ {t('manageAITraders', language)} +

+
+
+ +
+ + + + + + + +
+
+ ) +} diff --git a/web/src/components/traders/sections/SignalSourceWarning.tsx b/web/src/components/traders/sections/SignalSourceWarning.tsx new file mode 100644 index 00000000..5c49f341 --- /dev/null +++ b/web/src/components/traders/sections/SignalSourceWarning.tsx @@ -0,0 +1,54 @@ +import { AlertTriangle } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' + +interface SignalSourceWarningProps { + language: Language + onConfigure: () => void +} + +export function SignalSourceWarning({ + language, + onConfigure, +}: SignalSourceWarningProps) { + return ( +
+ +
+
+ ⚠️ {t('signalSourceNotConfigured', language)} +
+
+

{t('signalSourceWarningMessage', language)}

+

+ {t('solutions', language)} +

+
    +
  • 点击"{t('signalSource', language)}"按钮配置API地址
  • +
  • 或在交易员配置中禁用"使用币种池"和"使用OI Top"
  • +
  • 或在交易员配置中设置自定义币种列表
  • +
+
+ +
+
+ ) +} diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx new file mode 100644 index 00000000..95307aa8 --- /dev/null +++ b/web/src/components/traders/sections/TradersGrid.tsx @@ -0,0 +1,172 @@ +import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react' +import { t, type Language } from '../../../i18n/translations' +import { getModelDisplayName } from '../index' +import type { TraderInfo } from '../../../types' + +interface TradersGridProps { + language: Language + traders: TraderInfo[] | undefined + onTraderSelect: (traderId: string) => void + onEditTrader: (traderId: string) => void + onDeleteTrader: (traderId: string) => void + onToggleTrader: (traderId: string, running: boolean) => void +} + +export function TradersGrid({ + language, + traders, + onTraderSelect, + onEditTrader, + onDeleteTrader, + onToggleTrader, +}: TradersGridProps) { + if (!traders || traders.length === 0) { + return ( +
+ +
+ {t('noTraders', language)} +
+
+ {t('createFirstTrader', language)} +
+
+ ) + } + + return ( +
+ {traders.map((trader) => ( +
+
+
+ +
+
+
+ {trader.trader_name} +
+
+ {getModelDisplayName( + trader.ai_model.split('_').pop() || trader.ai_model + )}{' '} + Model • {trader.exchange_id?.toUpperCase()} +
+
+
+ +
+ {/* Status */} +
+
+ {trader.is_running + ? t('running', language) + : t('stopped', language)} +
+
+ + {/* Actions: 禁止换行,超出横向滚动 */} +
+ + + + + + + +
+
+
+ ))} +
+ ) +} diff --git a/web/src/components/traders/utils.ts b/web/src/components/traders/utils.ts new file mode 100644 index 00000000..6235e006 --- /dev/null +++ b/web/src/components/traders/utils.ts @@ -0,0 +1,19 @@ +// 获取友好的AI模型名称 +export function getModelDisplayName(modelId: string): string { + switch (modelId.toLowerCase()) { + case 'deepseek': + return 'DeepSeek' + case 'qwen': + return 'Qwen' + case 'claude': + return 'Claude' + default: + return modelId.toUpperCase() + } +} + +// 提取下划线后面的名称部分 +export function getShortName(fullName: string): string { + const parts = fullName.split('_') + return parts.length > 1 ? parts[parts.length - 1] : fullName +} diff --git a/web/src/hooks/useTraderActions.ts b/web/src/hooks/useTraderActions.ts new file mode 100644 index 00000000..47d3c163 --- /dev/null +++ b/web/src/hooks/useTraderActions.ts @@ -0,0 +1,639 @@ +import { api } from '../lib/api' +import type { + TraderInfo, + CreateTraderRequest, + TraderConfigData, + AIModel, + Exchange, +} from '../types' +import { t } from '../i18n/translations' +import { confirmToast } from '../lib/notify' +import { toast } from 'sonner' +import type { Language } from '../i18n/translations' + +interface UseTraderActionsParams { + traders: TraderInfo[] | undefined + allModels: AIModel[] + allExchanges: Exchange[] + supportedModels: AIModel[] + supportedExchanges: Exchange[] + language: Language + mutateTraders: () => Promise + setAllModels: (models: AIModel[]) => void + setAllExchanges: (exchanges: Exchange[]) => void + setUserSignalSource: (config: { + coinPoolUrl: string + oiTopUrl: string + }) => void + setShowCreateModal: (show: boolean) => void + setShowEditModal: (show: boolean) => void + setShowModelModal: (show: boolean) => void + setShowExchangeModal: (show: boolean) => void + setShowSignalSourceModal: (show: boolean) => void + setEditingModel: (modelId: string | null) => void + setEditingExchange: (exchangeId: string | null) => void + editingTrader: TraderConfigData | null + setEditingTrader: (trader: TraderConfigData | null) => void +} + +export function useTraderActions({ + traders, + allModels, + allExchanges, + supportedModels, + supportedExchanges, + language, + mutateTraders, + setAllModels, + setAllExchanges, + setUserSignalSource, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + editingTrader, + setEditingTrader, +}: UseTraderActionsParams) { + // 检查模型是否正在被运行中的交易员使用(用于UI禁用) + const isModelInUse = (modelId: string) => { + return traders?.some((t) => t.ai_model === modelId && t.is_running) || false + } + + // 检查交易所是否正在被运行中的交易员使用(用于UI禁用) + const isExchangeInUse = (exchangeId: string) => { + return ( + traders?.some((t) => t.exchange_id === exchangeId && t.is_running) || + false + ) + } + + // 检查模型是否被任何交易员使用(包括停止状态的) + const isModelUsedByAnyTrader = (modelId: string) => { + return traders?.some((t) => t.ai_model === modelId) || false + } + + // 检查交易所是否被任何交易员使用(包括停止状态的) + const isExchangeUsedByAnyTrader = (exchangeId: string) => { + return traders?.some((t) => t.exchange_id === exchangeId) || false + } + + // 获取使用特定模型的交易员列表 + const getTradersUsingModel = (modelId: string) => { + return traders?.filter((t) => t.ai_model === modelId) || [] + } + + // 获取使用特定交易所的交易员列表 + const getTradersUsingExchange = (exchangeId: string) => { + return traders?.filter((t) => t.exchange_id === exchangeId) || [] + } + + const handleCreateTrader = async (data: CreateTraderRequest) => { + try { + const model = allModels?.find((m) => m.id === data.ai_model_id) + const exchange = allExchanges?.find((e) => e.id === data.exchange_id) + + if (!model?.enabled) { + toast.error(t('modelNotConfigured', language)) + return + } + + if (!exchange?.enabled) { + toast.error(t('exchangeNotConfigured', language)) + return + } + + await toast.promise(api.createTrader(data), { + loading: '正在创建…', + success: '创建成功', + error: '创建失败', + }) + setShowCreateModal(false) + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to create trader:', error) + toast.error(t('createTraderFailed', language)) + } + } + + const handleEditTrader = async (traderId: string) => { + try { + const traderConfig = await api.getTraderConfig(traderId) + setEditingTrader(traderConfig) + setShowEditModal(true) + } catch (error) { + console.error('Failed to fetch trader config:', error) + toast.error(t('getTraderConfigFailed', language)) + } + } + + const handleSaveEditTrader = async (data: CreateTraderRequest) => { + if (!editingTrader || !editingTrader.trader_id) return + + try { + const enabledModels = allModels?.filter((m) => m.enabled) || [] + const enabledExchanges = + allExchanges?.filter((e) => { + if (!e.enabled) return false + + // Aster 交易所需要特殊字段 + if (e.id === 'aster') { + return ( + e.asterUser && + e.asterUser.trim() !== '' && + e.asterSigner && + e.asterSigner.trim() !== '' + ) + } + + // Hyperliquid 需要钱包地址 + if (e.id === 'hyperliquid') { + return ( + e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== '' + ) + } + + return true + }) || [] + + const model = enabledModels?.find((m) => m.id === data.ai_model_id) + const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id) + + if (!model) { + toast.error(t('modelConfigNotExist', language)) + return + } + + if (!exchange) { + toast.error(t('exchangeConfigNotExist', language)) + return + } + + const request = { + name: data.name, + ai_model_id: data.ai_model_id, + exchange_id: data.exchange_id, + initial_balance: data.initial_balance, + scan_interval_minutes: data.scan_interval_minutes, + btc_eth_leverage: data.btc_eth_leverage, + altcoin_leverage: data.altcoin_leverage, + trading_symbols: data.trading_symbols, + custom_prompt: data.custom_prompt, + override_base_prompt: data.override_base_prompt, + system_prompt_template: data.system_prompt_template, + is_cross_margin: data.is_cross_margin, + use_coin_pool: data.use_coin_pool, + use_oi_top: data.use_oi_top, + } + + await toast.promise(api.updateTrader(editingTrader.trader_id, request), { + loading: '正在保存…', + success: '保存成功', + error: '保存失败', + }) + setShowEditModal(false) + setEditingTrader(null) + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to update trader:', error) + toast.error(t('updateTraderFailed', language)) + } + } + + const handleDeleteTrader = async (traderId: string) => { + { + const ok = await confirmToast(t('confirmDeleteTrader', language)) + if (!ok) return + } + + try { + await toast.promise(api.deleteTrader(traderId), { + loading: '正在删除…', + success: '删除成功', + error: '删除失败', + }) + + // Immediately refresh traders list for better UX + await mutateTraders() + } catch (error) { + console.error('Failed to delete trader:', error) + toast.error(t('deleteTraderFailed', language)) + } + } + + const handleToggleTrader = async (traderId: string, running: boolean) => { + try { + if (running) { + await toast.promise(api.stopTrader(traderId), { + loading: '正在停止…', + success: '已停止', + error: '停止失败', + }) + } else { + await toast.promise(api.startTrader(traderId), { + loading: '正在启动…', + success: '已启动', + error: '启动失败', + }) + } + + // Immediately refresh traders list to update running status + await mutateTraders() + } catch (error) { + console.error('Failed to toggle trader:', error) + toast.error(t('operationFailed', language)) + } + } + + const handleModelClick = (modelId: string) => { + if (!isModelInUse(modelId)) { + setEditingModel(modelId) + setShowModelModal(true) + } + } + + const handleExchangeClick = (exchangeId: string) => { + if (!isExchangeInUse(exchangeId)) { + setEditingExchange(exchangeId) + setShowExchangeModal(true) + } + } + + // 通用删除配置处理函数 + const handleDeleteConfig = async (config: { + id: string + type: 'model' | 'exchange' + checkInUse: (id: string) => boolean + getUsingTraders: (id: string) => any[] + cannotDeleteKey: string + confirmDeleteKey: string + allItems: T[] | undefined + clearFields: (item: T) => T + buildRequest: (items: T[]) => any + updateApi: (request: any) => Promise + refreshApi: () => Promise + setItems: (items: T[]) => void + closeModal: () => void + errorKey: string + }) => { + // 检查是否有交易员正在使用 + if (config.checkInUse(config.id)) { + const usingTraders = config.getUsingTraders(config.id) + const traderNames = usingTraders.map((t) => t.trader_name).join(', ') + toast.error( + `${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}` + ) + return + } + + { + const ok = await confirmToast(t(config.confirmDeleteKey, language)) + if (!ok) return + } + + try { + const updatedItems = + config.allItems?.map((item) => + item.id === config.id ? config.clearFields(item) : item + ) || [] + + const request = config.buildRequest(updatedItems) + await toast.promise(config.updateApi(request), { + loading: '正在更新配置…', + success: '配置已更新', + error: '更新配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedItems = await config.refreshApi() + config.setItems(refreshedItems) + + config.closeModal() + } catch (error) { + console.error(`Failed to delete ${config.type} config:`, error) + toast.error(t(config.errorKey, language)) + } + } + + const handleDeleteModel = async (modelId: string) => { + await handleDeleteConfig({ + id: modelId, + type: 'model', + checkInUse: isModelUsedByAnyTrader, + getUsingTraders: getTradersUsingModel, + cannotDeleteKey: 'cannotDeleteModelInUse', + confirmDeleteKey: 'confirmDeleteModel', + allItems: allModels, + clearFields: (m) => ({ + ...m, + apiKey: '', + customApiUrl: '', + customModelName: '', + enabled: false, + }), + buildRequest: (models) => ({ + models: Object.fromEntries( + models.map((model) => [ + model.provider, + { + enabled: model.enabled, + api_key: model.apiKey || '', + custom_api_url: model.customApiUrl || '', + custom_model_name: model.customModelName || '', + }, + ]) + ), + }), + updateApi: api.updateModelConfigs, + refreshApi: api.getModelConfigs, + setItems: (items) => { + // 使用函数式更新确保状态正确更新 + setAllModels([...items]) + }, + closeModal: () => { + setShowModelModal(false) + setEditingModel(null) + }, + errorKey: 'deleteConfigFailed', + }) + } + + const handleSaveModel = async ( + modelId: string, + apiKey: string, + customApiUrl?: string, + customModelName?: string + ) => { + try { + // 创建或更新用户的模型配置 + const existingModel = allModels?.find((m) => m.id === modelId) + let updatedModels + + // 找到要配置的模型(优先从已配置列表,其次从支持列表) + const modelToUpdate = + existingModel || supportedModels?.find((m) => m.id === modelId) + if (!modelToUpdate) { + toast.error(t('modelNotExist', language)) + return + } + + if (existingModel) { + // 更新现有配置 + updatedModels = + allModels?.map((m) => + m.id === modelId + ? { + ...m, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } + : m + ) || [] + } else { + // 添加新配置 + const newModel = { + ...modelToUpdate, + apiKey, + customApiUrl: customApiUrl || '', + customModelName: customModelName || '', + enabled: true, + } + updatedModels = [...(allModels || []), newModel] + } + + const request = { + models: Object.fromEntries( + updatedModels.map((model) => [ + model.provider, // 使用 provider 而不是 id + { + enabled: model.enabled, + api_key: model.apiKey || '', + custom_api_url: model.customApiUrl || '', + custom_model_name: model.customModelName || '', + }, + ]) + ), + } + + await toast.promise(api.updateModelConfigs(request), { + loading: '正在更新模型配置…', + success: '模型配置已更新', + error: '更新模型配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedModels = await api.getModelConfigs() + setAllModels(refreshedModels) + + setShowModelModal(false) + setEditingModel(null) + } catch (error) { + console.error('Failed to save model config:', error) + toast.error(t('saveConfigFailed', language)) + } + } + + const handleDeleteExchange = async (exchangeId: string) => { + await handleDeleteConfig({ + id: exchangeId, + type: 'exchange', + checkInUse: isExchangeUsedByAnyTrader, + getUsingTraders: getTradersUsingExchange, + cannotDeleteKey: 'cannotDeleteExchangeInUse', + confirmDeleteKey: 'confirmDeleteExchange', + allItems: allExchanges, + clearFields: (e) => ({ + ...e, + apiKey: '', + secretKey: '', + hyperliquidWalletAddr: '', + asterUser: '', + asterSigner: '', + asterPrivateKey: '', + enabled: false, + }), + buildRequest: (exchanges) => ({ + exchanges: Object.fromEntries( + exchanges.map((exchange) => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false, + hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', + aster_user: exchange.asterUser || '', + aster_signer: exchange.asterSigner || '', + aster_private_key: exchange.asterPrivateKey || '', + }, + ]) + ), + }), + updateApi: api.updateExchangeConfigsEncrypted, + refreshApi: api.getExchangeConfigs, + setItems: (items) => { + // 使用函数式更新确保状态正确更新 + setAllExchanges([...items]) + }, + closeModal: () => { + setShowExchangeModal(false) + setEditingExchange(null) + }, + errorKey: 'deleteExchangeConfigFailed', + }) + } + + const handleSaveExchange = async ( + exchangeId: string, + apiKey: string, + secretKey?: string, + testnet?: boolean, + hyperliquidWalletAddr?: string, + asterUser?: string, + asterSigner?: string, + asterPrivateKey?: string + ) => { + try { + // 找到要配置的交易所(从supportedExchanges中) + const exchangeToUpdate = supportedExchanges?.find( + (e) => e.id === exchangeId + ) + if (!exchangeToUpdate) { + toast.error(t('exchangeNotExist', language)) + return + } + + // 创建或更新用户的交易所配置 + const existingExchange = allExchanges?.find((e) => e.id === exchangeId) + let updatedExchanges + + if (existingExchange) { + // 更新现有配置 + updatedExchanges = + allExchanges?.map((e) => + e.id === exchangeId + ? { + ...e, + apiKey, + secretKey, + testnet, + hyperliquidWalletAddr, + asterUser, + asterSigner, + asterPrivateKey, + enabled: true, + } + : e + ) || [] + } else { + // 添加新配置 + const newExchange = { + ...exchangeToUpdate, + apiKey, + secretKey, + testnet, + hyperliquidWalletAddr, + asterUser, + asterSigner, + asterPrivateKey, + enabled: true, + } + updatedExchanges = [...(allExchanges || []), newExchange] + } + + const request = { + exchanges: Object.fromEntries( + updatedExchanges.map((exchange) => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false, + hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', + aster_user: exchange.asterUser || '', + aster_signer: exchange.asterSigner || '', + aster_private_key: exchange.asterPrivateKey || '', + }, + ]) + ), + } + + await toast.promise(api.updateExchangeConfigsEncrypted(request), { + loading: '正在更新交易所配置…', + success: '交易所配置已更新', + error: '更新交易所配置失败', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedExchanges = await api.getExchangeConfigs() + setAllExchanges(refreshedExchanges) + + setShowExchangeModal(false) + setEditingExchange(null) + } catch (error) { + console.error('Failed to save exchange config:', error) + toast.error(t('saveConfigFailed', language)) + } + } + + const handleAddModel = () => { + setEditingModel(null) + setShowModelModal(true) + } + + const handleAddExchange = () => { + setEditingExchange(null) + setShowExchangeModal(true) + } + + const handleSaveSignalSource = async ( + coinPoolUrl: string, + oiTopUrl: string + ) => { + try { + await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), { + loading: '正在保存…', + success: '保存成功', + error: '保存失败', + }) + setUserSignalSource({ coinPoolUrl, oiTopUrl }) + setShowSignalSourceModal(false) + } catch (error) { + console.error('Failed to save signal source:', error) + toast.error(t('saveSignalSourceFailed', language)) + } + } + + return { + // 辅助函数 + isModelInUse, + isExchangeInUse, + isModelUsedByAnyTrader, + isExchangeUsedByAnyTrader, + getTradersUsingModel, + getTradersUsingExchange, + + // 事件处理函数 + handleCreateTrader, + handleEditTrader, + handleSaveEditTrader, + handleDeleteTrader, + handleToggleTrader, + handleAddModel, + handleAddExchange, + handleModelClick, + handleExchangeClick, + handleSaveModel, + handleDeleteModel, + handleSaveExchange, + handleDeleteExchange, + handleSaveSignalSource, + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 39ab8e9e..9b70edd4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { DecisionRecord, Statistics, TraderInfo, + TraderConfigData, AIModel, Exchange, CreateTraderRequest, @@ -94,7 +95,7 @@ export const api = { if (!res.ok) throw new Error('更新自定义策略失败') }, - async getTraderConfig(traderId: string): Promise { + async getTraderConfig(traderId: string): Promise { const res = await httpClient.get( `${API_BASE}/traders/${traderId}/config`, getAuthHeaders() diff --git a/web/src/pages/AITradersPage.tsx b/web/src/pages/AITradersPage.tsx new file mode 100644 index 00000000..b662d6e7 --- /dev/null +++ b/web/src/pages/AITradersPage.tsx @@ -0,0 +1,248 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import useSWR from 'swr' +import { api } from '../lib/api' +import { useLanguage } from '../contexts/LanguageContext' +import { useAuth } from '../contexts/AuthContext' +import { useTradersConfigStore, useTradersModalStore } from '../stores' +import { useTraderActions } from '../hooks/useTraderActions' +import { TraderConfigModal } from '../components/TraderConfigModal' +import { + SignalSourceModal, + ModelConfigModal, + ExchangeConfigModal, +} from '../components/traders' +import { PageHeader } from '../components/traders/sections/PageHeader' +import { SignalSourceWarning } from '../components/traders/sections/SignalSourceWarning' +import { AIModelsSection } from '../components/traders/sections/AIModelsSection' +import { ExchangesSection } from '../components/traders/sections/ExchangesSection' +import { TradersGrid } from '../components/traders/sections/TradersGrid' + +interface AITradersPageProps { + onTraderSelect?: (traderId: string) => void +} + +export function AITradersPage({ onTraderSelect }: AITradersPageProps) { + const { language } = useLanguage() + const { user, token } = useAuth() + const navigate = useNavigate() + + // Zustand stores + const { + allModels, + allExchanges, + supportedModels, + supportedExchanges, + configuredModels, + configuredExchanges, + userSignalSource, + loadConfigs, + setAllModels, + setAllExchanges, + setUserSignalSource, + } = useTradersConfigStore() + + const { + showCreateModal, + showEditModal, + showModelModal, + showExchangeModal, + showSignalSourceModal, + editingModel, + editingExchange, + editingTrader, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + setEditingTrader, + } = useTradersModalStore() + + // SWR for traders data + const { data: traders, mutate: mutateTraders } = useSWR( + user && token ? 'traders' : null, + api.getTraders, + { refreshInterval: 5000 } + ) + + // Load configurations + useEffect(() => { + loadConfigs(user, token) + }, [user, token, loadConfigs]) + + // Business logic hook + const { + isModelInUse, + isExchangeInUse, + handleCreateTrader, + handleEditTrader, + handleSaveEditTrader, + handleDeleteTrader, + handleToggleTrader, + handleAddModel, + handleAddExchange, + handleModelClick, + handleExchangeClick, + handleSaveModel, + handleDeleteModel, + handleSaveExchange, + handleDeleteExchange, + handleSaveSignalSource, + } = useTraderActions({ + traders, + allModels, + allExchanges, + supportedModels, + supportedExchanges, + language, + mutateTraders, + setAllModels, + setAllExchanges, + setUserSignalSource, + setShowCreateModal, + setShowEditModal, + setShowModelModal, + setShowExchangeModal, + setShowSignalSourceModal, + setEditingModel, + setEditingExchange, + editingTrader, + setEditingTrader, + }) + + // 计算派生状态 + const enabledModels = allModels?.filter((m) => m.enabled) || [] + const enabledExchanges = + allExchanges?.filter((e) => { + if (!e.enabled) return false + if (e.id === 'aster') { + return e.asterUser?.trim() && e.asterSigner?.trim() + } + if (e.id === 'hyperliquid') { + return e.hyperliquidWalletAddr?.trim() + } + return true + }) || [] + + // 检查是否需要显示信号源警告 + const showSignalWarning = + traders?.some((t) => t.use_coin_pool || t.use_oi_top) && + !userSignalSource.coinPoolUrl && + !userSignalSource.oiTopUrl + + // 处理交易员查看 + const handleTraderSelect = (traderId: string) => { + if (onTraderSelect) { + onTraderSelect(traderId) + } else { + navigate(`/dashboard?trader=${traderId}`) + } + } + + return ( +
+ {/* Header */} + setShowSignalSourceModal(true)} + onCreateTrader={() => setShowCreateModal(true)} + /> + + {/* Signal Source Warning */} + {showSignalWarning && ( + setShowSignalSourceModal(true)} + /> + )} + + {/* Configuration Status */} +
+ + + +
+ + {/* Traders Grid */} + + + {/* Modals */} + setShowCreateModal(false)} + isEditMode={false} + availableModels={enabledModels} + availableExchanges={enabledExchanges} + onSave={handleCreateTrader} + /> + + setShowEditModal(false)} + isEditMode={true} + traderData={editingTrader} + availableModels={enabledModels} + availableExchanges={enabledExchanges} + onSave={handleSaveEditTrader} + /> + + {showModelModal && ( + setShowModelModal(false)} + language={language} + /> + )} + + {showExchangeModal && ( + setShowExchangeModal(false)} + language={language} + /> + )} + + {showSignalSourceModal && ( + setShowSignalSourceModal(false)} + language={language} + /> + )} +
+ ) +} diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index b49164cf..d0793e48 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -7,7 +7,7 @@ import { LoginPage } from '../components/LoginPage' import { RegisterPage } from '../components/RegisterPage' import { ResetPasswordPage } from '../components/ResetPasswordPage' import { CompetitionPage } from '../components/CompetitionPage' -import { AITradersPage } from '../components/AITradersPage' +import { AITradersPage } from '../pages/AITradersPage' import TraderDashboard from '../pages/TraderDashboard' export const router = createBrowserRouter([ diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts new file mode 100644 index 00000000..e825ae04 --- /dev/null +++ b/web/src/stores/index.ts @@ -0,0 +1,2 @@ +export { useTradersConfigStore } from './tradersConfigStore' +export { useTradersModalStore } from './tradersModalStore' diff --git a/web/src/stores/tradersConfigStore.ts b/web/src/stores/tradersConfigStore.ts new file mode 100644 index 00000000..b58f3a20 --- /dev/null +++ b/web/src/stores/tradersConfigStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand' +import type { AIModel, Exchange } from '../types' +import { api } from '../lib/api' + +interface SignalSource { + coinPoolUrl: string + oiTopUrl: string +} + +interface TradersConfigState { + // 数据 + allModels: AIModel[] + allExchanges: Exchange[] + supportedModels: AIModel[] + supportedExchanges: Exchange[] + userSignalSource: SignalSource + + // 计算属性 + configuredModels: AIModel[] + configuredExchanges: Exchange[] + + // Actions + setAllModels: (models: AIModel[]) => void + setAllExchanges: (exchanges: Exchange[]) => void + setSupportedModels: (models: AIModel[]) => void + setSupportedExchanges: (exchanges: Exchange[]) => void + setUserSignalSource: (source: SignalSource) => void + + // 异步加载 + loadConfigs: (user: any, token: string | null) => Promise + + // 重置 + reset: () => void +} + +const initialState = { + allModels: [], + allExchanges: [], + supportedModels: [], + supportedExchanges: [], + userSignalSource: { coinPoolUrl: '', oiTopUrl: '' }, + configuredModels: [], + configuredExchanges: [], +} + +export const useTradersConfigStore = create((set, get) => ({ + ...initialState, + + setAllModels: (models) => { + set({ allModels: models }) + // 更新 configuredModels + const configuredModels = models.filter((m) => { + return m.enabled || (m.customApiUrl && m.customApiUrl.trim() !== '') + }) + set({ configuredModels }) + }, + + setAllExchanges: (exchanges) => { + set({ allExchanges: exchanges }) + // 更新 configuredExchanges + const configuredExchanges = exchanges.filter((e) => { + if (e.id === 'aster') { + return e.asterUser && e.asterUser.trim() !== '' + } + if (e.id === 'hyperliquid') { + return e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== '' + } + // 修复: 添加 enabled 判断,与原始逻辑保持一致 + return e.enabled || (e.apiKey && e.apiKey.trim() !== '') + }) + set({ configuredExchanges }) + }, + + setSupportedModels: (models) => set({ supportedModels: models }), + setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }), + setUserSignalSource: (source) => set({ userSignalSource: source }), + + loadConfigs: async (user, token) => { + if (!user || !token) { + // 未登录时只加载公开的支持模型和交易所 + try { + const [supportedModels, supportedExchanges] = await Promise.all([ + api.getSupportedModels(), + api.getSupportedExchanges(), + ]) + get().setSupportedModels(supportedModels) + get().setSupportedExchanges(supportedExchanges) + } catch (err) { + console.error('Failed to load supported configs:', err) + } + return + } + + try { + const [ + modelConfigs, + exchangeConfigs, + supportedModels, + supportedExchanges, + ] = await Promise.all([ + api.getModelConfigs(), + api.getExchangeConfigs(), + api.getSupportedModels(), + api.getSupportedExchanges(), + ]) + + get().setAllModels(modelConfigs) + get().setAllExchanges(exchangeConfigs) + get().setSupportedModels(supportedModels) + get().setSupportedExchanges(supportedExchanges) + + // 加载用户信号源配置 + try { + const signalSource = await api.getUserSignalSource() + get().setUserSignalSource({ + coinPoolUrl: signalSource.coin_pool_url || '', + oiTopUrl: signalSource.oi_top_url || '', + }) + } catch (error) { + console.log('📡 用户信号源配置暂未设置') + } + } catch (error) { + console.error('Failed to load configs:', error) + } + }, + + reset: () => set(initialState), +})) diff --git a/web/src/stores/tradersModalStore.ts b/web/src/stores/tradersModalStore.ts new file mode 100644 index 00000000..e70a1912 --- /dev/null +++ b/web/src/stores/tradersModalStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand' +import type { TraderConfigData } from '../types' + +interface TradersModalState { + // Modal 显示状态 + showCreateModal: boolean + showEditModal: boolean + showModelModal: boolean + showExchangeModal: boolean + showSignalSourceModal: boolean + + // 编辑状态 + editingModel: string | null + editingExchange: string | null + editingTrader: TraderConfigData | null + + // Actions + setShowCreateModal: (show: boolean) => void + setShowEditModal: (show: boolean) => void + setShowModelModal: (show: boolean) => void + setShowExchangeModal: (show: boolean) => void + setShowSignalSourceModal: (show: boolean) => void + + setEditingModel: (modelId: string | null) => void + setEditingExchange: (exchangeId: string | null) => void + setEditingTrader: (trader: TraderConfigData | null) => void + + // 便捷方法 + openModelModal: (modelId?: string) => void + closeModelModal: () => void + openExchangeModal: (exchangeId?: string) => void + closeExchangeModal: () => void + + // 重置 + reset: () => void +} + +const initialState = { + showCreateModal: false, + showEditModal: false, + showModelModal: false, + showExchangeModal: false, + showSignalSourceModal: false, + editingModel: null, + editingExchange: null, + editingTrader: null, +} + +export const useTradersModalStore = create((set) => ({ + ...initialState, + + setShowCreateModal: (show) => set({ showCreateModal: show }), + setShowEditModal: (show) => set({ showEditModal: show }), + setShowModelModal: (show) => set({ showModelModal: show }), + setShowExchangeModal: (show) => set({ showExchangeModal: show }), + setShowSignalSourceModal: (show) => set({ showSignalSourceModal: show }), + + setEditingModel: (modelId) => set({ editingModel: modelId }), + setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }), + setEditingTrader: (trader) => set({ editingTrader: trader }), + + openModelModal: (modelId) => { + set({ editingModel: modelId || null, showModelModal: true }) + }, + + closeModelModal: () => { + set({ showModelModal: false, editingModel: null }) + }, + + openExchangeModal: (exchangeId) => { + set({ editingExchange: exchangeId || null, showExchangeModal: true }) + }, + + closeExchangeModal: () => { + set({ showExchangeModal: false, editingExchange: null }) + }, + + reset: () => set(initialState), +}))