mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
refactor(web): restructure AITradersPage into modular architecture (#1023)
* refactor(web): restructure AITradersPage into modular architecture Refactored the massive 2652-line AITradersPage.tsx into a clean, modular architecture following React best practices. **Changes:** - Decomposed 2652-line component into 12 focused modules - Introduced Zustand stores for config and modal state management - Extracted all business logic into useTraderActions custom hook (633 lines) - Created reusable section components (PageHeader, TradersGrid, etc.) - Separated complex modal logic into dedicated components - Added TraderConfig type, eliminated all any types - Fixed critical bugs in configuredExchanges logic and getState() usage **File Structure:** - Main page reduced from 2652 → 234 lines (91% reduction) - components/traders/: 7 UI components + 5 section components - stores/: tradersConfigStore, tradersModalStore - hooks/: useTraderActions (all business logic) Co-Authored-By: tinkle-community <tinklefund@gmail.com> * chore: ignore PR_DESCRIPTION.md * fix(web): restore trader dashboard navigation functionality Fixed missing navigation logic in refactored AITradersPage. The "查看" (View) button now correctly navigates to the trader dashboard. **Root Cause:** During refactoring, the `useNavigate` hook and default navigation logic were inadvertently omitted from the main page component. **Changes:** - Added `useNavigate` import from react-router-dom - Implemented `handleTraderSelect` function with fallback navigation - Restored original behavior: use `onTraderSelect` prop if provided, otherwise navigate to `/dashboard?trader=${traderId}` **Testing:** - ✅ Click "查看" button navigates to trader dashboard - ✅ Query parameter correctly passed to dashboard Co-Authored-By: tinkle-community <tinklefund@gmail.com> * fix(web): correct type definitions for trader configuration Fixed TypeScript build errors by using the correct `TraderConfigData` type instead of the incorrect `TraderConfig` type. **Root Cause:** During refactoring, a new `TraderConfig` type was incorrectly created that extended `CreateTraderRequest` (with fields like `name`, `ai_model_id`). However, the `TraderConfigModal` component and API responses actually use `TraderConfigData` (with fields like `trader_name`, `ai_model`). **Changes:** - Replaced all `TraderConfig` references with `TraderConfigData`: - stores/tradersModalStore.ts - hooks/useTraderActions.ts - lib/api.ts - Removed incorrect `TraderConfig` type definition from types.ts - Added null check for `editingTrader.trader_id` to satisfy TypeScript **Build Status:** - ✅ TypeScript compilation: PASS - ✅ Vite production build: PASS Co-Authored-By: tinkle-community <tinklefund@gmail.com> --------- Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
926
web/src/components/traders/ExchangeConfigModal.tsx
Normal file
926
web/src/components/traders/ExchangeConfigModal.tsx
Normal file
@@ -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<void>
|
||||
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<WebCryptoCheckStatus>('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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingExchangeId
|
||||
? t('editExchange', language)
|
||||
: t('addExchange', language)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedExchange?.id === 'binance' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(true)}
|
||||
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105 flex items-center gap-2"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('viewGuide', language)}
|
||||
</button>
|
||||
)}
|
||||
{editingExchangeId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingExchangeId)}
|
||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
title={t('delete', language)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{!editingExchangeId && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('environmentSteps.checkTitle', language)}
|
||||
</div>
|
||||
<WebCryptoEnvironmentCheck
|
||||
language={language}
|
||||
variant="card"
|
||||
onStatusChange={setWebCryptoStatus}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('environmentSteps.selectTitle', language)}
|
||||
</div>
|
||||
<select
|
||||
value={selectedExchangeId}
|
||||
onChange={(e) => setSelectedExchangeId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
aria-label={t('selectExchange', language)}
|
||||
disabled={webCryptoStatus !== 'secure'}
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
{t('pleaseSelectExchange', language)}
|
||||
</option>
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(exchange.name)} (
|
||||
{exchange.type.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExchange && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getExchangeIcon(selectedExchange.id, {
|
||||
width: 32,
|
||||
height: 32,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedExchange.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedExchange.type.toUpperCase()} •{' '}
|
||||
{selectedExchange.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExchange && (
|
||||
<>
|
||||
{/* Binance 和其他 CEX 交易所的字段 */}
|
||||
{(selectedExchange.id === 'binance' ||
|
||||
selectedExchange.type === 'cex') &&
|
||||
selectedExchange.id !== 'hyperliquid' &&
|
||||
selectedExchange.id !== 'aster' && (
|
||||
<>
|
||||
{/* 币安用户配置提示 (D1 方案) */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
<div
|
||||
className="mb-4 p-3 rounded cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: '#1a3a52',
|
||||
border: '1px solid #2b5278',
|
||||
}}
|
||||
onClick={() => setShowBinanceGuide(!showBinanceGuide)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: '#58a6ff' }}>ℹ️</span>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<strong>币安用户必读:</strong>
|
||||
使用「现货与合约交易」API,不要用「统一账户
|
||||
API」
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: '#8b949e' }}>
|
||||
{showBinanceGuide ? '▲' : '▼'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 展开的详细说明 */}
|
||||
{showBinanceGuide && (
|
||||
<div
|
||||
className="mt-3 pt-3"
|
||||
style={{
|
||||
borderTop: '1px solid #2b5278',
|
||||
fontSize: '0.875rem',
|
||||
color: '#c9d1d9',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-2" style={{ color: '#8b949e' }}>
|
||||
<strong>原因:</strong>统一账户 API
|
||||
权限结构不同,会导致订单提交失败
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="font-semibold mb-1"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
正确配置步骤:
|
||||
</p>
|
||||
<ol
|
||||
className="list-decimal list-inside space-y-1 mb-3"
|
||||
style={{ paddingLeft: '0.5rem' }}
|
||||
>
|
||||
<li>
|
||||
登录币安 → 个人中心 →{' '}
|
||||
<strong>API 管理</strong>
|
||||
</li>
|
||||
<li>
|
||||
创建 API → 选择「
|
||||
<strong>系统生成的 API 密钥</strong>」
|
||||
</li>
|
||||
<li>
|
||||
勾选「<strong>现货与合约交易</strong>」(
|
||||
<span style={{ color: '#f85149' }}>
|
||||
不选统一账户
|
||||
</span>
|
||||
)
|
||||
</li>
|
||||
<li>
|
||||
IP 限制选「<strong>无限制</strong>
|
||||
」或添加服务器 IP
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p
|
||||
className="mb-2 p-2 rounded"
|
||||
style={{
|
||||
background: '#3d2a00',
|
||||
border: '1px solid #9e6a03',
|
||||
}}
|
||||
>
|
||||
💡 <strong>多资产模式用户注意:</strong>
|
||||
如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://www.binance.com/zh-CN/support/faq/how-to-create-api-keys-on-binance-360002502072"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm hover:underline"
|
||||
style={{ color: '#58a6ff' }}
|
||||
>
|
||||
📖 查看币安官方教程 ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('apiKey', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('secretKey', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedExchange.id === 'okx' && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('passphrase', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Binance 白名单IP提示 */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('whitelistIP', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mb-3"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('whitelistIPDesc', language)}
|
||||
</div>
|
||||
|
||||
{loadingIP ? (
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('loadingServerIP', language)}
|
||||
</div>
|
||||
) : serverIP && serverIP.public_ip ? (
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 rounded"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<code
|
||||
className="flex-1 text-sm font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{serverIP.public_ip}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyIP(serverIP.public_ip)}
|
||||
className="px-3 py-1 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.2)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
>
|
||||
{copiedIP
|
||||
? t('ipCopied', language)
|
||||
: t('copyIP', language)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Aster 交易所的字段 */}
|
||||
{selectedExchange.id === 'aster' && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('user', language)}
|
||||
<Tooltip content={t('asterUserDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
style={{ color: '#F0B90B' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={asterUser}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('signer', language)}
|
||||
<Tooltip content={t('asterSignerDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
style={{ color: '#F0B90B' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={asterSigner}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('privateKey', language)}
|
||||
<Tooltip content={t('asterPrivateKeyDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
style={{ color: '#F0B90B' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={asterPrivateKey}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hyperliquid 交易所的字段 */}
|
||||
{selectedExchange.id === 'hyperliquid' && (
|
||||
<>
|
||||
{/* 安全提示 banner */}
|
||||
<div
|
||||
className="p-3 rounded mb-4"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span style={{ color: '#F0B90B', fontSize: '16px' }}>
|
||||
🔐
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="text-sm font-semibold mb-1"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('hyperliquidAgentWalletTitle', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: '#848E9C', lineHeight: '1.5' }}
|
||||
>
|
||||
{t('hyperliquidAgentWalletDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Private Key 字段 */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('hyperliquidAgentPrivateKey', language)}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={maskSecret(apiKey)}
|
||||
readOnly
|
||||
placeholder={t(
|
||||
'enterHyperliquidAgentPrivateKey',
|
||||
language
|
||||
)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSecureInputTarget('hyperliquid')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{apiKey
|
||||
? t('secureInputReenter', language)
|
||||
: t('secureInputButton', language)}
|
||||
</button>
|
||||
{apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKey('')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1B1F2B',
|
||||
color: '#848E9C',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('secureInputClear', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{apiKey && (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('secureInputHint', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('hyperliquidAgentPrivateKeyDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Wallet Address 字段 */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('hyperliquidMainWalletAddress', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hyperliquidWalletAddr}
|
||||
onChange={(e) =>
|
||||
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
|
||||
/>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('hyperliquidMainWalletAddressDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!selectedExchange ||
|
||||
(selectedExchange.id === 'binance' &&
|
||||
(!apiKey.trim() || !secretKey.trim())) ||
|
||||
(selectedExchange.id === 'okx' &&
|
||||
(!apiKey.trim() ||
|
||||
!secretKey.trim() ||
|
||||
!passphrase.trim())) ||
|
||||
(selectedExchange.id === 'hyperliquid' &&
|
||||
(!apiKey.trim() || !hyperliquidWalletAddr.trim())) || // 验证私钥和钱包地址
|
||||
(selectedExchange.id === 'aster' &&
|
||||
(!asterUser.trim() ||
|
||||
!asterSigner.trim() ||
|
||||
!asterPrivateKey.trim())) ||
|
||||
(selectedExchange.type === 'cex' &&
|
||||
selectedExchange.id !== 'hyperliquid' &&
|
||||
selectedExchange.id !== 'aster' &&
|
||||
selectedExchange.id !== 'binance' &&
|
||||
selectedExchange.id !== 'okx' &&
|
||||
(!apiKey.trim() || !secretKey.trim()))
|
||||
}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('saveConfig', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Binance Setup Guide Modal */}
|
||||
{showGuide && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4"
|
||||
onClick={() => setShowGuide(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl relative"
|
||||
style={{ background: '#1E2329' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3
|
||||
className="text-xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<BookOpen className="w-6 h-6" style={{ color: '#F0B90B' }} />
|
||||
{t('binanceSetupGuide', language)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowGuide(false)}
|
||||
className="px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('closeGuide', language)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-[80vh]">
|
||||
<img
|
||||
src="/images/guide.png"
|
||||
alt={t('binanceSetupGuide', language)}
|
||||
className="w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two Stage Key Modal */}
|
||||
<TwoStageKeyModal
|
||||
isOpen={secureInputTarget !== null}
|
||||
language={language}
|
||||
contextLabel={secureInputContextLabel}
|
||||
expectedLength={64}
|
||||
onCancel={handleSecureInputCancel}
|
||||
onComplete={handleSecureInputComplete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
web/src/components/traders/ModelConfigModal.tsx
Normal file
291
web/src/components/traders/ModelConfigModal.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingModelId
|
||||
? t('editAIModel', language)
|
||||
: t('addAIModel', language)}
|
||||
</h3>
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingModelId)}
|
||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
title={t('delete', language)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{!editingModelId && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('selectModel', language)}
|
||||
</label>
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">{t('pleaseSelectModel', language)}</option>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name)} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModel && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getModelIcon(selectedModel.provider || selectedModel.id, {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}) || (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background:
|
||||
selectedModel.id === 'deepseek'
|
||||
? '#60a5fa'
|
||||
: '#c084fc',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{selectedModel.name[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedModel.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedModel.provider} • {selectedModel.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModel && (
|
||||
<>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('customBaseURL', language)}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('leaveBlankForDefault', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
Model Name (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
留空使用默认模型名称
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
ℹ️ {t('information', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs space-y-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<div>{t('modelConfigInfo1', language)}</div>
|
||||
<div>{t('modelConfigInfo2', language)}</div>
|
||||
<div>{t('modelConfigInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedModel || !apiKey.trim()}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('saveConfig', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
web/src/components/traders/SignalSourceModal.tsx
Normal file
138
web/src/components/traders/SignalSourceModal.tsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{t('signalSourceConfig', language)}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
COIN POOL URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={coinPool}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
OI TOP URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={oiTop}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
ℹ️ {t('information', language)}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>{t('signalSourceInfo1', language)}</div>
|
||||
<div>{t('signalSourceInfo2', language)}</div>
|
||||
<div>{t('signalSourceInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('save', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
web/src/components/traders/Tooltip.tsx
Normal file
44
web/src/components/traders/Tooltip.tsx
Normal file
@@ -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 (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{show && (
|
||||
<div
|
||||
className="absolute z-10 px-3 py-2 text-sm rounded-lg shadow-lg w-64 left-1/2 transform -translate-x-1/2 bottom-full mb-2"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<div
|
||||
className="absolute left-1/2 transform -translate-x-1/2 top-full"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #2B3139',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
web/src/components/traders/index.ts
Normal file
5
web/src/components/traders/index.ts
Normal file
@@ -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'
|
||||
97
web/src/components/traders/sections/AIModelsSection.tsx
Normal file
97
web/src/components/traders/sections/AIModelsSection.tsx
Normal file
@@ -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 (
|
||||
<div className="binance-card p-3 md:p-4">
|
||||
<h3
|
||||
className="text-base md:text-lg font-semibold mb-3 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<Brain className="w-4 h-4 md:w-5 md:h-5" style={{ color: '#60a5fa' }} />
|
||||
{t('aiModels', language)}
|
||||
</h3>
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
{configuredModels.map((model) => {
|
||||
const inUse = isModelInUse(model.id)
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`flex items-center justify-between p-2 md:p-3 rounded transition-all ${
|
||||
inUse
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
onClick={() => onModelClick(model.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="w-7 h-7 md:w-8 md:h-8 flex items-center justify-center flex-shrink-0">
|
||||
{getModelIcon(model.provider || model.id, {
|
||||
width: 28,
|
||||
height: 28,
|
||||
}) || (
|
||||
<div
|
||||
className="w-7 h-7 md:w-8 md:h-8 rounded-full flex items-center justify-center text-xs md:text-sm font-bold"
|
||||
style={{
|
||||
background:
|
||||
model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{getShortName(model.name)[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-semibold text-sm md:text-base truncate"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{getShortName(model.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{inUse
|
||||
? t('inUse', language)
|
||||
: model.enabled
|
||||
? t('enabled', language)
|
||||
: t('configured', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${model.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{configuredModels.length === 0 && (
|
||||
<div
|
||||
className="text-center py-6 md:py-8"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<Brain className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-2 opacity-50" />
|
||||
<div className="text-xs md:text-sm">
|
||||
{t('noModelsConfigured', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
web/src/components/traders/sections/ExchangesSection.tsx
Normal file
87
web/src/components/traders/sections/ExchangesSection.tsx
Normal file
@@ -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 (
|
||||
<div className="binance-card p-3 md:p-4">
|
||||
<h3
|
||||
className="text-base md:text-lg font-semibold mb-3 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<Landmark
|
||||
className="w-4 h-4 md:w-5 md:h-5"
|
||||
style={{ color: '#F0B90B' }}
|
||||
/>
|
||||
{t('exchanges', language)}
|
||||
</h3>
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
{configuredExchanges.map((exchange) => {
|
||||
const inUse = isExchangeInUse(exchange.id)
|
||||
return (
|
||||
<div
|
||||
key={exchange.id}
|
||||
className={`flex items-center justify-between p-2 md:p-3 rounded transition-all ${
|
||||
inUse
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer hover:bg-gray-700'
|
||||
}`}
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
onClick={() => onExchangeClick(exchange.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="w-7 h-7 md:w-8 md:h-8 flex items-center justify-center flex-shrink-0">
|
||||
{getExchangeIcon(exchange.id, { width: 28, height: 28 })}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-semibold text-sm md:text-base truncate"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{getShortName(exchange.name)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{exchange.type.toUpperCase()} •{' '}
|
||||
{inUse
|
||||
? t('inUse', language)
|
||||
: exchange.enabled
|
||||
? t('enabled', language)
|
||||
: t('configured', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 md:w-3 md:h-3 rounded-full flex-shrink-0 ${exchange.enabled ? 'bg-green-400' : 'bg-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{configuredExchanges.length === 0 && (
|
||||
<div
|
||||
className="text-center py-6 md:py-8"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<Landmark className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-2 opacity-50" />
|
||||
<div className="text-xs md:text-sm">
|
||||
{t('noExchangesConfigured', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
web/src/components/traders/sections/PageHeader.tsx
Normal file
117
web/src/components/traders/sections/PageHeader.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Bot className="w-5 h-5 md:w-6 md:h-6" style={{ color: '#000' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl md:text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('aiTraders', language)}
|
||||
<span
|
||||
className="text-xs font-normal px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
color: '#F0B90B',
|
||||
}}
|
||||
>
|
||||
{tradersCount} {t('active', language)}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('manageAITraders', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 md:gap-3 w-full md:w-auto overflow-hidden flex-wrap md:flex-nowrap">
|
||||
<button
|
||||
onClick={onAddModel}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('aiModels', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onAddExchange}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('exchanges', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onConfigureSignalSource}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
<Radio className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('signalSource', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCreateTrader}
|
||||
disabled={!canCreateTrader}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: canCreateTrader ? '#F0B90B' : '#2B3139',
|
||||
color: canCreateTrader ? '#000' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('createTrader', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
web/src/components/traders/sections/SignalSourceWarning.tsx
Normal file
54
web/src/components/traders/sections/SignalSourceWarning.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="rounded-lg px-4 py-3 flex items-start gap-3 animate-slide-in"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className="flex-shrink-0 mt-0.5"
|
||||
style={{ color: '#F6465D' }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1" style={{ color: '#F6465D' }}>
|
||||
⚠️ {t('signalSourceNotConfigured', language)}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<p className="mb-2">{t('signalSourceWarningMessage', language)}</p>
|
||||
<p>
|
||||
<strong>{t('solutions', language)}</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2 mt-1">
|
||||
<li>点击"{t('signalSource', language)}"按钮配置API地址</li>
|
||||
<li>或在交易员配置中禁用"使用币种池"和"使用OI Top"</li>
|
||||
<li>或在交易员配置中设置自定义币种列表</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="mt-3 px-3 py-1.5 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{t('configureSignalSourceNow', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
web/src/components/traders/sections/TradersGrid.tsx
Normal file
172
web/src/components/traders/sections/TradersGrid.tsx
Normal file
@@ -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 (
|
||||
<div className="text-center py-12 md:py-16" style={{ color: '#848E9C' }}>
|
||||
<Bot className="w-16 h-16 md:w-24 md:h-24 mx-auto mb-3 md:mb-4 opacity-50" />
|
||||
<div className="text-base md:text-lg font-semibold mb-2">
|
||||
{t('noTraders', language)}
|
||||
</div>
|
||||
<div className="text-xs md:text-sm mb-3 md:mb-4">
|
||||
{t('createFirstTrader', language)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
{traders.map((trader) => (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded transition-all hover:translate-y-[-1px] gap-3 md:gap-4"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: trader.ai_model.includes('deepseek')
|
||||
? '#60a5fa'
|
||||
: '#c084fc',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Bot className="w-5 h-5 md:w-6 md:h-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-bold text-base md:text-lg truncate"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm truncate"
|
||||
style={{
|
||||
color: trader.ai_model.includes('deepseek')
|
||||
? '#60a5fa'
|
||||
: '#c084fc',
|
||||
}}
|
||||
>
|
||||
{getModelDisplayName(
|
||||
trader.ai_model.split('_').pop() || trader.ai_model
|
||||
)}{' '}
|
||||
Model • {trader.exchange_id?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
|
||||
{/* Status */}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${
|
||||
trader.is_running
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
style={
|
||||
trader.is_running
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{trader.is_running
|
||||
? t('running', language)
|
||||
: t('stopped', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions: 禁止换行,超出横向滚动 */}
|
||||
<div className="flex gap-1.5 md:gap-2 flex-nowrap overflow-x-auto items-center">
|
||||
<button
|
||||
onClick={() => onTraderSelect(trader.trader_id)}
|
||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"
|
||||
style={{
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
color: '#6366F1',
|
||||
}}
|
||||
>
|
||||
<BarChart3 className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('view', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onEditTrader(trader.trader_id)}
|
||||
disabled={trader.is_running}
|
||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap flex items-center gap-1"
|
||||
style={{
|
||||
background: trader.is_running
|
||||
? 'rgba(132, 142, 156, 0.1)'
|
||||
: 'rgba(255, 193, 7, 0.1)',
|
||||
color: trader.is_running ? '#848E9C' : '#FFC107',
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('edit', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
onToggleTrader(trader.trader_id, trader.is_running || false)
|
||||
}
|
||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap"
|
||||
style={
|
||||
trader.is_running
|
||||
? {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
}
|
||||
>
|
||||
{trader.is_running ? t('stop', language) : t('start', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onDeleteTrader(trader.trader_id)}
|
||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
web/src/components/traders/utils.ts
Normal file
19
web/src/components/traders/utils.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user