mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-02 02:21:19 +08:00
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and ⏳ placeholder
- Account state injection at conversation start (models, exchanges,
strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
574 lines
23 KiB
TypeScript
574 lines
23 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../types'
|
|
import { useLanguage } from '../contexts/LanguageContext'
|
|
import { t } from '../i18n/translations'
|
|
import { toast } from 'sonner'
|
|
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
|
import { httpClient } from '../lib/httpClient'
|
|
|
|
// 提取下划线后面的名称部分
|
|
function getShortName(fullName: string): string {
|
|
const parts = fullName.split('_')
|
|
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
|
}
|
|
|
|
// 交易所注册链接配置
|
|
const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: boolean }> = {
|
|
binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true },
|
|
okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true },
|
|
bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true },
|
|
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
|
|
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
|
|
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
|
|
}
|
|
|
|
import type { TraderConfigData } from '../types'
|
|
|
|
// 表单内部状态类型
|
|
interface FormState {
|
|
trader_id?: string
|
|
trader_name: string
|
|
ai_model: string
|
|
exchange_id: string
|
|
strategy_id: string
|
|
is_cross_margin: boolean
|
|
show_in_competition: boolean
|
|
scan_interval_minutes: number
|
|
initial_balance?: number
|
|
}
|
|
|
|
interface TraderConfigModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
traderData?: TraderConfigData | null
|
|
isEditMode?: boolean
|
|
availableModels?: AIModel[]
|
|
availableExchanges?: Exchange[]
|
|
onSave?: (data: CreateTraderRequest) => Promise<void>
|
|
}
|
|
|
|
export function TraderConfigModal({
|
|
isOpen,
|
|
onClose,
|
|
traderData,
|
|
isEditMode = false,
|
|
availableModels = [],
|
|
availableExchanges = [],
|
|
onSave,
|
|
}: TraderConfigModalProps) {
|
|
const { language } = useLanguage()
|
|
const [formData, setFormData] = useState<FormState>({
|
|
trader_name: '',
|
|
ai_model: '',
|
|
exchange_id: '',
|
|
strategy_id: '',
|
|
is_cross_margin: true,
|
|
show_in_competition: true,
|
|
scan_interval_minutes: 3,
|
|
})
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [strategies, setStrategies] = useState<Strategy[]>([])
|
|
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
|
|
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
|
|
|
|
// 获取用户的策略列表
|
|
useEffect(() => {
|
|
const fetchStrategies = async () => {
|
|
try {
|
|
const result = await httpClient.get<{ strategies: Strategy[] }>('/api/strategies')
|
|
if (result.success && result.data?.strategies) {
|
|
const strategyList = result.data.strategies
|
|
setStrategies(strategyList)
|
|
// 如果没有选择策略,默认选中激活的策略
|
|
if (!formData.strategy_id && !isEditMode) {
|
|
const activeStrategy = strategyList.find(s => s.is_active)
|
|
if (activeStrategy) {
|
|
setFormData(prev => ({ ...prev, strategy_id: activeStrategy.id }))
|
|
} else if (strategyList.length > 0) {
|
|
setFormData(prev => ({ ...prev, strategy_id: strategyList[0].id }))
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch strategies:', error)
|
|
}
|
|
}
|
|
if (isOpen) {
|
|
fetchStrategies()
|
|
}
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (traderData) {
|
|
setFormData({
|
|
...traderData,
|
|
strategy_id: traderData.strategy_id || '',
|
|
})
|
|
} else if (!isEditMode) {
|
|
setFormData({
|
|
trader_name: '',
|
|
ai_model: availableModels[0]?.id || '',
|
|
exchange_id: availableExchanges[0]?.id || '',
|
|
strategy_id: '',
|
|
is_cross_margin: true,
|
|
show_in_competition: true,
|
|
scan_interval_minutes: 3,
|
|
})
|
|
}
|
|
}, [traderData, isEditMode, availableModels, availableExchanges])
|
|
|
|
if (!isOpen) return null
|
|
|
|
const handleInputChange = (field: keyof FormState, value: any) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const handleFetchCurrentBalance = async () => {
|
|
if (!isEditMode || !traderData?.trader_id) {
|
|
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
|
|
return
|
|
}
|
|
|
|
setIsFetchingBalance(true)
|
|
setBalanceFetchError('')
|
|
|
|
try {
|
|
const result = await httpClient.get<{
|
|
total_equity?: number
|
|
balance?: number
|
|
}>(`/api/account?trader_id=${traderData.trader_id}`)
|
|
|
|
if (result.success && result.data) {
|
|
const currentBalance =
|
|
result.data.total_equity || result.data.balance || 0
|
|
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
|
toast.success(t('balanceFetched', language))
|
|
} else {
|
|
throw new Error(result.message || t('balanceFetchFailed', language))
|
|
}
|
|
} catch (error) {
|
|
console.error(t('balanceFetchFailed', language) + ':', error)
|
|
setBalanceFetchError(t('balanceFetchNetworkError', language))
|
|
} finally {
|
|
setIsFetchingBalance(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!onSave) return
|
|
|
|
setIsSaving(true)
|
|
try {
|
|
const saveData: CreateTraderRequest = {
|
|
name: formData.trader_name,
|
|
ai_model_id: formData.ai_model,
|
|
exchange_id: formData.exchange_id,
|
|
strategy_id: formData.strategy_id,
|
|
is_cross_margin: formData.is_cross_margin,
|
|
show_in_competition: formData.show_in_competition,
|
|
scan_interval_minutes: formData.scan_interval_minutes,
|
|
}
|
|
|
|
// 只在编辑模式时包含initial_balance
|
|
if (isEditMode && formData.initial_balance !== undefined) {
|
|
saveData.initial_balance = formData.initial_balance
|
|
}
|
|
|
|
await toast.promise(onSave(saveData), {
|
|
loading: t('saving', language),
|
|
success: t('saveSuccess', language),
|
|
error: t('saveFailed', language),
|
|
})
|
|
onClose()
|
|
} catch (error) {
|
|
console.error(t('saveFailed', language) + ':', error)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
|
|
<div
|
|
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full my-8"
|
|
style={{ maxHeight: 'calc(100vh - 4rem)' }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky top-0 z-10 rounded-t-xl">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center text-black">
|
|
{isEditMode ? (
|
|
<Pencil className="w-5 h-5" />
|
|
) : (
|
|
<Plus className="w-5 h-5" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-[#EAECEF]">
|
|
{isEditMode ? t('editTrader', language) : t('createTrader', language)}
|
|
</h2>
|
|
<p className="text-sm text-[#848E9C] mt-1">
|
|
{isEditMode ? t('editTraderConfig', language) : t('selectStrategyAndConfigParams', language)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
|
|
>
|
|
<IconX className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div
|
|
className="p-6 space-y-6 overflow-y-auto"
|
|
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
|
>
|
|
{/* Basic Info */}
|
|
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
|
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
|
<span className="text-[#F0B90B]">1</span> {t('basicConfig', language)}
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('traderNameRequired', language)}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.trader_name}
|
|
onChange={(e) =>
|
|
handleInputChange('trader_name', e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
placeholder={t('enterTraderNamePlaceholder', language)}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('aiModelRequired', language)}
|
|
</label>
|
|
<select
|
|
value={formData.ai_model}
|
|
onChange={(e) =>
|
|
handleInputChange('ai_model', e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
>
|
|
{availableModels.map((model) => (
|
|
<option key={model.id} value={model.id}>
|
|
{getShortName(model.name || model.id).toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('exchangeRequired', language)}
|
|
</label>
|
|
<select
|
|
value={formData.exchange_id}
|
|
onChange={(e) =>
|
|
handleInputChange('exchange_id', e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
>
|
|
{availableExchanges.map((exchange) => (
|
|
<option key={exchange.id} value={exchange.id}>
|
|
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
|
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{/* Exchange Registration Link */}
|
|
{formData.exchange_id && (() => {
|
|
// Find the selected exchange to get its type
|
|
const selectedExchange = availableExchanges.find(e => e.id === formData.exchange_id)
|
|
const exchangeType = selectedExchange?.exchange_type?.toLowerCase() || ''
|
|
const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType]
|
|
if (!regLink) return null
|
|
return (
|
|
<a
|
|
href={regLink.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1.5 text-xs text-[#848E9C] hover:text-[#F0B90B] transition-colors"
|
|
>
|
|
<UserPlus className="w-3.5 h-3.5" />
|
|
<span>{t('noExchangeAccount', language)}</span>
|
|
{regLink.hasReferral && (
|
|
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
|
|
{t('discount', language)}
|
|
</span>
|
|
)}
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Strategy Selection */}
|
|
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
|
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
|
<span className="text-[#F0B90B]">2</span> {t('selectTradingStrategy', language)}
|
|
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('useStrategy', language)}
|
|
</label>
|
|
<select
|
|
value={formData.strategy_id}
|
|
onChange={(e) =>
|
|
handleInputChange('strategy_id', e.target.value)
|
|
}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
>
|
|
<option value="">{t('noStrategyManual', language)}</option>
|
|
{strategies.map((strategy) => (
|
|
<option key={strategy.id} value={strategy.id}>
|
|
{strategy.name}
|
|
{strategy.is_active ? t('strategyActive', language) : ''}
|
|
{strategy.is_default ? t('strategyDefault', language) : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{strategies.length === 0 && (
|
|
<p className="text-xs text-[#848E9C] mt-2">
|
|
{t('noStrategyHint', language)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Strategy Preview */}
|
|
{selectedStrategy && (
|
|
<div className="mt-3 p-4 bg-[#1E2329] border border-[#2B3139] rounded-lg">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-[#F0B90B] text-sm font-medium">
|
|
{t('strategyDetails', language)}
|
|
</span>
|
|
{selectedStrategy.is_active && (
|
|
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
|
|
{t('activating', language)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-[#848E9C] mb-2">
|
|
{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
|
<div>
|
|
{t('coinSource', language)}: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
|
|
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
|
|
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
|
</div>
|
|
<div>
|
|
{t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Trading Parameters */}
|
|
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
|
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
|
<span className="text-[#F0B90B]">3</span> {t('tradingParams', language)}
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('marginMode', language)}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInputChange('is_cross_margin', true)}
|
|
className={`flex-1 px-3 py-2 rounded text-sm ${
|
|
formData.is_cross_margin
|
|
? 'bg-[#F0B90B] text-black'
|
|
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
|
}`}
|
|
>
|
|
{t('crossMargin', language)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleInputChange('is_cross_margin', false)
|
|
}
|
|
className={`flex-1 px-3 py-2 rounded text-sm ${
|
|
!formData.is_cross_margin
|
|
? 'bg-[#F0B90B] text-black'
|
|
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
|
}`}
|
|
>
|
|
{t('isolatedMargin', language)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('aiScanInterval', language)}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={formData.scan_interval_minutes}
|
|
onChange={(e) => {
|
|
const parsedValue = Number(e.target.value)
|
|
const safeValue = Number.isFinite(parsedValue)
|
|
? Math.max(3, parsedValue)
|
|
: 3
|
|
handleInputChange('scan_interval_minutes', safeValue)
|
|
}}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
min="3"
|
|
max="60"
|
|
step="1"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{t('scanIntervalRecommend', language)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Competition visibility */}
|
|
<div>
|
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
|
{t('competitionDisplay', language)}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInputChange('show_in_competition', true)}
|
|
className={`flex-1 px-3 py-2 rounded text-sm ${
|
|
formData.show_in_competition
|
|
? 'bg-[#F0B90B] text-black'
|
|
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
|
}`}
|
|
>
|
|
{t('show', language)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInputChange('show_in_competition', false)}
|
|
className={`flex-1 px-3 py-2 rounded text-sm ${
|
|
!formData.show_in_competition
|
|
? 'bg-[#F0B90B] text-black'
|
|
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
|
|
}`}
|
|
>
|
|
{t('hide', language)}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-[#848E9C] mt-1">
|
|
{t('hiddenInCompetition', language)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Initial Balance (Edit mode only) */}
|
|
{isEditMode && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm text-[#EAECEF]">
|
|
{t('initialBalanceLabel', language)}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={handleFetchCurrentBalance}
|
|
disabled={isFetchingBalance}
|
|
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
|
>
|
|
{isFetchingBalance ? t('fetching', language) : t('fetchCurrentBalance', language)}
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
value={formData.initial_balance || 0}
|
|
onChange={(e) =>
|
|
handleInputChange(
|
|
'initial_balance',
|
|
Number(e.target.value)
|
|
)
|
|
}
|
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
|
min="100"
|
|
step="0.01"
|
|
/>
|
|
<p className="text-xs text-[#848E9C] mt-1">
|
|
{t('balanceUpdateHint', language)}
|
|
</p>
|
|
{balanceFetchError && (
|
|
<p className="text-xs text-red-500 mt-1">
|
|
{balanceFetchError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create mode info */}
|
|
{!isEditMode && (
|
|
<div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="w-4 h-4 text-[#F0B90B]"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" x2="12" y1="8" y2="12" />
|
|
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
</svg>
|
|
<span className="text-sm text-[#848E9C]">
|
|
{t('autoFetchBalanceInfo', language)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky bottom-0 z-10 rounded-b-xl">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
|
|
>
|
|
{t('cancel', language)}
|
|
</button>
|
|
{onSave && (
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={
|
|
isSaving ||
|
|
!formData.trader_name ||
|
|
!formData.ai_model ||
|
|
!formData.exchange_id
|
|
}
|
|
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
|
|
>
|
|
{isSaving ? t('saving', language) : isEditMode ? t('editTrader', language) : t('createTraderButton', language)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|