Files
nofx/web/src/components/TraderConfigModal.tsx
2025-12-11 00:47:53 +08:00

574 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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://lighter.xyz', hasReferral: false },
}
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('只有在编辑模式下才能获取当前余额')
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('已获取当前余额')
} else {
throw new Error(result.message || '获取余额失败')
}
} catch (error) {
console.error('获取余额失败:', error)
setBalanceFetchError('获取余额失败,请检查网络连接')
} 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 || undefined,
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: '正在保存…',
success: '保存成功',
error: '保存失败',
})
onClose()
} catch (error) {
console.error('保存失败:', 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 ? '修改交易员' : '创建交易员'}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? '修改交易员配置' : '选择策略并配置基础参数'}
</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>
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
<span className="text-red-500">*</span>
</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="请输入交易员名称"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
AI模型 <span className="text-red-500">*</span>
</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">
<span className="text-red-500">*</span>
</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></span>
{regLink.hasReferral && (
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
</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>
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
使
</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="">-- 使--</option>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
{strategy.is_active ? ' (当前激活)' : ''}
{strategy.is_default ? ' [默认]' : ''}
</option>
))}
</select>
{strategies.length === 0 && (
<p className="text-xs text-[#848E9C] mt-2">
</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">
</span>
{selectedStrategy.is_active && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
</span>
)}
</div>
<p className="text-sm text-[#848E9C] mb-2">
{selectedStrategy.description || '无描述'}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' :
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div>
<div>
: {((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>
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
</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]'
}`}
>
</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]'
}`}
>
</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">
</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]'
}`}
>
</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]'
}`}
>
</button>
</div>
<p className="text-xs text-[#848E9C] mt-1">
</p>
</div>
{/* Initial Balance (Edit mode only) */}
{isEditMode && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
</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 ? '获取中...' : '获取当前余额'}
</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">
/
</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]">
</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]"
>
</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 ? '保存中...' : isEditMode ? '保存修改' : '创建交易员'}
</button>
)}
</div>
</div>
</div>
)
}