mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 03:21:04 +08:00
account system、custom prompt
This commit is contained in:
@@ -4,8 +4,34 @@ import { api } from '../lib/api';
|
||||
import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { getExchangeIcon } from './ExchangeIcons';
|
||||
import { getModelIcon } from './ModelIcons';
|
||||
|
||||
export function AITradersPage() {
|
||||
// 获取友好的AI模型名称
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
switch (modelId.toLowerCase()) {
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
case 'qwen':
|
||||
return 'Qwen';
|
||||
case 'claude':
|
||||
return 'Claude';
|
||||
case 'gpt4':
|
||||
case 'gpt-4':
|
||||
return 'GPT-4';
|
||||
case 'gpt3.5':
|
||||
case 'gpt-3.5':
|
||||
return 'GPT-3.5';
|
||||
default:
|
||||
return modelId.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
interface AITradersPageProps {
|
||||
onTraderSelect?: (traderId: string) => void;
|
||||
}
|
||||
|
||||
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const { language } = useLanguage();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showModelModal, setShowModelModal] = useState(false);
|
||||
@@ -14,6 +40,8 @@ export function AITradersPage() {
|
||||
const [editingExchange, setEditingExchange] = useState<string | null>(null);
|
||||
const [allModels, setAllModels] = useState<AIModel[]>([]);
|
||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([]);
|
||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([]);
|
||||
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([]);
|
||||
|
||||
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
|
||||
'traders',
|
||||
@@ -25,22 +53,41 @@ export function AITradersPage() {
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const [modelConfigs, exchangeConfigs] = await Promise.all([
|
||||
console.log('🔄 开始加载模型和交易所配置...');
|
||||
const [modelConfigs, exchangeConfigs, supportedModels, supportedExchanges] = await Promise.all([
|
||||
api.getModelConfigs(),
|
||||
api.getExchangeConfigs()
|
||||
api.getExchangeConfigs(),
|
||||
api.getSupportedModels(),
|
||||
api.getSupportedExchanges()
|
||||
]);
|
||||
console.log('✅ 用户模型配置加载成功:', modelConfigs);
|
||||
console.log('✅ 用户交易所配置加载成功:', exchangeConfigs);
|
||||
console.log('✅ 支持的模型加载成功:', supportedModels);
|
||||
console.log('✅ 支持的交易所加载成功:', supportedExchanges);
|
||||
setAllModels(modelConfigs);
|
||||
setAllExchanges(exchangeConfigs);
|
||||
setSupportedModels(supportedModels);
|
||||
setSupportedExchanges(supportedExchanges);
|
||||
} catch (error) {
|
||||
console.error('Failed to load configs:', error);
|
||||
console.error('❌ 加载配置失败:', error);
|
||||
}
|
||||
};
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
// 只显示已配置的模型和交易所
|
||||
const configuredModels = allModels.filter(m => m.enabled && m.apiKey);
|
||||
const configuredExchanges = allExchanges.filter(e => e.enabled && e.apiKey && (e.id === 'hyperliquid' || e.secretKey));
|
||||
// 显示所有用户的模型和交易所配置(用于调试)
|
||||
const configuredModels = allModels || [];
|
||||
const configuredExchanges = allExchanges || [];
|
||||
|
||||
// 只在创建交易员时使用已启用且配置完整的
|
||||
const enabledModels = allModels?.filter(m => m.enabled && m.apiKey) || [];
|
||||
const enabledExchanges = allExchanges?.filter(e => {
|
||||
if (!e.enabled || !e.apiKey) return false;
|
||||
// Hyperliquid 只需要私钥(作为apiKey),不需要secretKey
|
||||
if (e.id === 'hyperliquid') return true;
|
||||
// 其他交易所需要secretKey
|
||||
return e.secretKey && e.secretKey.trim() !== '';
|
||||
}) || [];
|
||||
|
||||
// 检查模型是否正在被运行中的交易员使用
|
||||
const isModelInUse = (modelId: string) => {
|
||||
@@ -52,10 +99,10 @@ export function AITradersPage() {
|
||||
return traders?.some(t => t.exchange_id === exchangeId && t.is_running) || false;
|
||||
};
|
||||
|
||||
const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number) => {
|
||||
const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number, customPrompt?: string, overrideBase?: boolean) => {
|
||||
try {
|
||||
const model = allModels.find(m => m.id === modelId);
|
||||
const exchange = allExchanges.find(e => e.id === exchangeId);
|
||||
const model = allModels?.find(m => m.id === modelId);
|
||||
const exchange = allExchanges?.find(e => e.id === exchangeId);
|
||||
|
||||
if (!model?.enabled) {
|
||||
alert(t('modelNotConfigured', language));
|
||||
@@ -71,7 +118,9 @@ export function AITradersPage() {
|
||||
name,
|
||||
ai_model_id: modelId,
|
||||
exchange_id: exchangeId,
|
||||
initial_balance: initialBalance
|
||||
initial_balance: initialBalance,
|
||||
custom_prompt: customPrompt,
|
||||
override_base_prompt: overrideBase
|
||||
};
|
||||
|
||||
await api.createTrader(request);
|
||||
@@ -127,9 +176,9 @@ export function AITradersPage() {
|
||||
if (!confirm('确定要删除此AI模型配置吗?')) return;
|
||||
|
||||
try {
|
||||
const updatedModels = allModels.map(m =>
|
||||
const updatedModels = allModels?.map(m =>
|
||||
m.id === modelId ? { ...m, apiKey: '', enabled: false } : m
|
||||
);
|
||||
) || [];
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
@@ -155,9 +204,27 @@ export function AITradersPage() {
|
||||
|
||||
const handleSaveModelConfig = async (modelId: string, apiKey: string) => {
|
||||
try {
|
||||
const updatedModels = allModels.map(m =>
|
||||
m.id === modelId ? { ...m, apiKey, enabled: true } : m
|
||||
);
|
||||
// 找到要配置的模型(从supportedModels中)
|
||||
const modelToUpdate = supportedModels?.find(m => m.id === modelId);
|
||||
if (!modelToUpdate) {
|
||||
alert('模型不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建或更新用户的模型配置
|
||||
const existingModel = allModels?.find(m => m.id === modelId);
|
||||
let updatedModels;
|
||||
|
||||
if (existingModel) {
|
||||
// 更新现有配置
|
||||
updatedModels = allModels?.map(m =>
|
||||
m.id === modelId ? { ...m, apiKey, enabled: true } : m
|
||||
) || [];
|
||||
} else {
|
||||
// 添加新配置
|
||||
const newModel = { ...modelToUpdate, apiKey, enabled: true };
|
||||
updatedModels = [...(allModels || []), newModel];
|
||||
}
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
@@ -172,7 +239,11 @@ export function AITradersPage() {
|
||||
};
|
||||
|
||||
await api.updateModelConfigs(request);
|
||||
setAllModels(updatedModels);
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedModels = await api.getModelConfigs();
|
||||
setAllModels(refreshedModels);
|
||||
|
||||
setShowModelModal(false);
|
||||
setEditingModel(null);
|
||||
} catch (error) {
|
||||
@@ -185,9 +256,9 @@ export function AITradersPage() {
|
||||
if (!confirm('确定要删除此交易所配置吗?')) return;
|
||||
|
||||
try {
|
||||
const updatedExchanges = allExchanges.map(e =>
|
||||
const updatedExchanges = allExchanges?.map(e =>
|
||||
e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e
|
||||
);
|
||||
) || [];
|
||||
|
||||
const request = {
|
||||
exchanges: Object.fromEntries(
|
||||
@@ -213,11 +284,29 @@ export function AITradersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => {
|
||||
const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, asterPrivateKey?: string) => {
|
||||
try {
|
||||
const updatedExchanges = allExchanges.map(e =>
|
||||
e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, enabled: true } : e
|
||||
);
|
||||
// 找到要配置的交易所(从supportedExchanges中)
|
||||
const exchangeToUpdate = supportedExchanges?.find(e => e.id === exchangeId);
|
||||
if (!exchangeToUpdate) {
|
||||
alert('交易所不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建或更新用户的交易所配置
|
||||
const existingExchange = allExchanges?.find(e => e.id === exchangeId);
|
||||
let updatedExchanges;
|
||||
|
||||
if (existingExchange) {
|
||||
// 更新现有配置
|
||||
updatedExchanges = allExchanges?.map(e =>
|
||||
e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true } : e
|
||||
) || [];
|
||||
} else {
|
||||
// 添加新配置
|
||||
const newExchange = { ...exchangeToUpdate, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true };
|
||||
updatedExchanges = [...(allExchanges || []), newExchange];
|
||||
}
|
||||
|
||||
const request = {
|
||||
exchanges: Object.fromEntries(
|
||||
@@ -227,14 +316,22 @@ export function AITradersPage() {
|
||||
enabled: exchange.enabled,
|
||||
api_key: exchange.apiKey || '',
|
||||
secret_key: exchange.secretKey || '',
|
||||
testnet: exchange.testnet || false
|
||||
testnet: exchange.testnet || false,
|
||||
hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '',
|
||||
aster_user: exchange.asterUser || '',
|
||||
aster_signer: exchange.asterSigner || '',
|
||||
aster_private_key: exchange.asterPrivateKey || ''
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
|
||||
await api.updateExchangeConfigs(request);
|
||||
setAllExchanges(updatedExchanges);
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedExchanges = await api.getExchangeConfigs();
|
||||
setAllExchanges(refreshedExchanges);
|
||||
|
||||
setShowExchangeModal(false);
|
||||
setEditingExchange(null);
|
||||
} catch (error) {
|
||||
@@ -339,21 +436,25 @@ export function AITradersPage() {
|
||||
onClick={() => handleModelClick(model.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{model.name[0]}
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{model.name[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>{model.name}</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('configured', language)}
|
||||
{inUse ? '正在使用' : model.enabled ? '已启用' : '已配置'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-3 h-3 rounded-full bg-green-400`} />
|
||||
<div className={`w-3 h-3 rounded-full ${model.enabled && model.apiKey ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -384,21 +485,17 @@ export function AITradersPage() {
|
||||
onClick={() => handleExchangeClick(exchange.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background: exchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
|
||||
color: '#000'
|
||||
}}>
|
||||
{exchange.name[0]}
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getExchangeIcon(exchange.id, { width: 32, height: 32 })}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>{exchange.name}</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{exchange.type.toUpperCase()} • {t('configured', language)}
|
||||
{exchange.type.toUpperCase()} • {inUse ? '正在使用' : exchange.enabled ? '已启用' : '已配置'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-3 h-3 rounded-full bg-green-400`} />
|
||||
<div className={`w-3 h-3 rounded-full ${exchange.enabled && exchange.apiKey ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -429,7 +526,7 @@ export function AITradersPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center text-xl"
|
||||
style={{
|
||||
background: trader.ai_model === 'deepseek' ? '#60a5fa' : '#c084fc',
|
||||
background: trader.ai_model.includes('deepseek') ? '#60a5fa' : '#c084fc',
|
||||
color: '#fff'
|
||||
}}>
|
||||
🤖
|
||||
@@ -439,9 +536,9 @@ export function AITradersPage() {
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-sm" style={{
|
||||
color: trader.ai_model === 'deepseek' ? '#60a5fa' : '#c084fc'
|
||||
color: trader.ai_model.includes('deepseek') ? '#60a5fa' : '#c084fc'
|
||||
}}>
|
||||
{trader.ai_model.toUpperCase()} Model • {trader.exchange_id?.toUpperCase()}
|
||||
{getModelDisplayName(trader.ai_model.split('_').pop() || trader.ai_model)} Model • {trader.exchange_id?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +560,15 @@ export function AITradersPage() {
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleTrader(trader.trader_id, trader.is_running)}
|
||||
onClick={() => onTraderSelect?.(trader.trader_id)}
|
||||
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: 'rgba(99, 102, 241, 0.1)', color: '#6366F1' }}
|
||||
>
|
||||
📊 查看
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleTrader(trader.trader_id, trader.is_running || false)}
|
||||
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={trader.is_running
|
||||
? { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
@@ -507,8 +612,8 @@ export function AITradersPage() {
|
||||
{/* Create Trader Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateTraderModal
|
||||
enabledModels={configuredModels}
|
||||
enabledExchanges={configuredExchanges}
|
||||
enabledModels={enabledModels}
|
||||
enabledExchanges={enabledExchanges}
|
||||
onCreate={handleCreateTrader}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
language={language}
|
||||
@@ -518,7 +623,7 @@ export function AITradersPage() {
|
||||
{/* Model Configuration Modal */}
|
||||
{showModelModal && (
|
||||
<ModelConfigModal
|
||||
allModels={allModels}
|
||||
allModels={supportedModels}
|
||||
editingModelId={editingModel}
|
||||
onSave={handleSaveModelConfig}
|
||||
onDelete={handleDeleteModelConfig}
|
||||
@@ -533,7 +638,7 @@ export function AITradersPage() {
|
||||
{/* Exchange Configuration Modal */}
|
||||
{showExchangeModal && (
|
||||
<ExchangeConfigModal
|
||||
allExchanges={allExchanges}
|
||||
allExchanges={supportedExchanges}
|
||||
editingExchangeId={editingExchange}
|
||||
onSave={handleSaveExchangeConfig}
|
||||
onDelete={handleDeleteExchangeConfig}
|
||||
@@ -558,7 +663,7 @@ function CreateTraderModal({
|
||||
}: {
|
||||
enabledModels: AIModel[];
|
||||
enabledExchanges: Exchange[];
|
||||
onCreate: (modelId: string, exchangeId: string, name: string, initialBalance: number) => void;
|
||||
onCreate: (modelId: string, exchangeId: string, name: string, initialBalance: number, customPrompt?: string, overrideBase?: boolean) => void;
|
||||
onClose: () => void;
|
||||
language: any;
|
||||
}) {
|
||||
@@ -571,12 +676,15 @@ function CreateTraderModal({
|
||||
const [selectedExchange, setSelectedExchange] = useState(defaultExchange?.id || '');
|
||||
const [traderName, setTraderName] = useState('');
|
||||
const [initialBalance, setInitialBalance] = useState(1000);
|
||||
const [customPrompt, setCustomPrompt] = useState('');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [overrideBase, setOverrideBase] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedModel || !selectedExchange || !traderName.trim()) return;
|
||||
|
||||
onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance);
|
||||
onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance, customPrompt.trim() || undefined, overrideBase);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -655,6 +763,68 @@ function CreateTraderModal({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings Toggle */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm font-semibold"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<span style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 0.2s' }}>▶</span>
|
||||
高级设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt Field - Show when advanced is toggled */}
|
||||
{showAdvanced && (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
自定义交易策略 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
placeholder="例如:专注于主流币种BTC/ETH/SOL,避免MEME币。使用保守策略,单笔仓位不超过账户的30%..."
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 rounded resize-none"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
输入自定义的交易策略和规则,将作为AI交易员的额外指导。留空使用默认策略。
|
||||
</div>
|
||||
|
||||
{/* Override Base Strategy Checkbox */}
|
||||
{customPrompt.trim() && (
|
||||
<div className="mt-3 p-3 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overrideBase}
|
||||
onChange={(e) => setOverrideBase(e.target.checked)}
|
||||
className="mt-1"
|
||||
style={{ accentColor: '#F6465D' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: '#F6465D' }}>
|
||||
覆盖基础交易策略
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
⚠️ 警告:勾选后将完全使用您的自定义策略,不再使用系统默认的风控和交易逻辑。
|
||||
这可能导致交易风险增加。仅在您完全理解交易逻辑时使用此选项。
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
@@ -699,7 +869,7 @@ function ModelConfigModal({
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
// 获取当前编辑的模型信息
|
||||
const selectedModel = allModels.find(m => m.id === selectedModelId);
|
||||
const selectedModel = allModels?.find(m => m.id === selectedModelId);
|
||||
|
||||
// 如果是编辑现有模型,初始化API Key
|
||||
useEffect(() => {
|
||||
@@ -715,17 +885,32 @@ function ModelConfigModal({
|
||||
onSave(selectedModelId, apiKey.trim());
|
||||
};
|
||||
|
||||
// 可选择的模型列表(排除已配置的,除非是当前编辑的)
|
||||
const availableModels = allModels.filter(m =>
|
||||
!m.enabled || !m.apiKey || m.id === editingModelId
|
||||
);
|
||||
// 可选择的模型列表(所有支持的模型)
|
||||
const availableModels = allModels || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg" style={{ background: '#1E2329' }}>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{editingModelId ? '编辑AI模型' : '添加AI模型'}
|
||||
</h3>
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingModelId ? '编辑AI模型' : '添加AI模型'}
|
||||
</h3>
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('确定要删除此AI模型配置吗?')) {
|
||||
onDelete(editingModelId);
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
title="删除配置"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!editingModelId && (
|
||||
@@ -753,12 +938,16 @@ function ModelConfigModal({
|
||||
{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 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background: selectedModel.id === 'deepseek' ? '#60a5fa' : '#c084fc',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{selectedModel.name[0]}
|
||||
<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' }}>{selectedModel.name}</div>
|
||||
@@ -792,18 +981,6 @@ function ModelConfigModal({
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
{editingModelId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete(editingModelId);
|
||||
}}
|
||||
className="px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedModelId || !apiKey.trim()}
|
||||
@@ -830,7 +1007,7 @@ function ExchangeConfigModal({
|
||||
}: {
|
||||
allExchanges: Exchange[];
|
||||
editingExchangeId: string | null;
|
||||
onSave: (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => void;
|
||||
onSave: (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, asterPrivateKey?: string) => void;
|
||||
onDelete: (exchangeId: string) => void;
|
||||
onClose: () => void;
|
||||
language: any;
|
||||
@@ -839,9 +1016,15 @@ function ExchangeConfigModal({
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [testnet, setTestnet] = useState(false);
|
||||
// Hyperliquid 特定字段
|
||||
const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('');
|
||||
// Aster 特定字段
|
||||
const [asterUser, setAsterUser] = useState('');
|
||||
const [asterSigner, setAsterSigner] = useState('');
|
||||
const [asterPrivateKey, setAsterPrivateKey] = useState('');
|
||||
|
||||
// 获取当前编辑的交易所信息
|
||||
const selectedExchange = allExchanges.find(e => e.id === selectedExchangeId);
|
||||
const selectedExchange = allExchanges?.find(e => e.id === selectedExchangeId);
|
||||
|
||||
// 如果是编辑现有交易所,初始化表单数据
|
||||
useEffect(() => {
|
||||
@@ -849,28 +1032,57 @@ function ExchangeConfigModal({
|
||||
setApiKey(selectedExchange.apiKey || '');
|
||||
setSecretKey(selectedExchange.secretKey || '');
|
||||
setTestnet(selectedExchange.testnet || false);
|
||||
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '');
|
||||
setAsterUser(selectedExchange.asterUser || '');
|
||||
setAsterSigner(selectedExchange.asterSigner || '');
|
||||
setAsterPrivateKey(selectedExchange.asterPrivateKey || '');
|
||||
}
|
||||
}, [editingExchangeId, selectedExchange]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedExchangeId || !apiKey.trim()) return;
|
||||
if (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim()) return;
|
||||
if (!selectedExchangeId) return;
|
||||
|
||||
onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet);
|
||||
// 根据交易所类型验证不同字段
|
||||
if (selectedExchange?.id === 'hyperliquid') {
|
||||
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return;
|
||||
} else if (selectedExchange?.id === 'aster') {
|
||||
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return;
|
||||
} else {
|
||||
// Binance 等其他交易所
|
||||
if (!apiKey.trim() || !secretKey.trim()) return;
|
||||
}
|
||||
|
||||
onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet,
|
||||
hyperliquidWalletAddr.trim(), asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim());
|
||||
};
|
||||
|
||||
// 可选择的交易所列表(排除已配置的,除非是当前编辑的)
|
||||
const availableExchanges = allExchanges.filter(e =>
|
||||
!e.enabled || !e.apiKey || e.id === editingExchangeId
|
||||
);
|
||||
// 可选择的交易所列表(所有支持的交易所)
|
||||
const availableExchanges = allExchanges || [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg" style={{ background: '#1E2329' }}>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{editingExchangeId ? '编辑交易所' : '添加交易所'}
|
||||
</h3>
|
||||
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{editingExchangeId ? '编辑交易所' : '添加交易所'}
|
||||
</h3>
|
||||
{editingExchangeId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('确定要删除此交易所配置吗?')) {
|
||||
onDelete(editingExchangeId);
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded hover:bg-red-100 transition-colors"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
title="删除配置"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!editingExchangeId && (
|
||||
@@ -898,12 +1110,8 @@ function ExchangeConfigModal({
|
||||
{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 rounded-full flex items-center justify-center text-sm font-bold"
|
||||
style={{
|
||||
background: selectedExchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
|
||||
color: '#000'
|
||||
}}>
|
||||
{selectedExchange.name[0]}
|
||||
<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' }}>{selectedExchange.name}</div>
|
||||
@@ -912,50 +1120,131 @@ function ExchangeConfigModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{selectedExchange.id === 'hyperliquid' ? 'Private Key (无需0x前缀)' : 'API Key'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={selectedExchange.id === 'hyperliquid' ? '请输入以太坊私钥' : `请输入 ${selectedExchange.name} API Key`}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedExchange.id !== 'hyperliquid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
Secret Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
placeholder={`请输入 ${selectedExchange.name} Secret Key`}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Binance 配置 */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
<>
|
||||
<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="请输入 Binance API Key"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
Secret Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
placeholder="请输入 Binance Secret Key"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedExchange.type === 'dex' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={testnet}
|
||||
onChange={(e) => setTestnet(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('useTestnet', language)}
|
||||
</label>
|
||||
</div>
|
||||
{/* Hyperliquid 配置 */}
|
||||
{selectedExchange.id === 'hyperliquid' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
Private Key (无需0x前缀)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="请输入以太坊私钥"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
钱包地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hyperliquidWalletAddr}
|
||||
onChange={(e) => setHyperliquidWalletAddr(e.target.value)}
|
||||
placeholder="请输入以太坊钱包地址"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={testnet}
|
||||
onChange={(e) => setTestnet(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm" style={{ color: '#EAECEF' }}>
|
||||
使用测试网
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Aster 配置 */}
|
||||
{selectedExchange.id === 'aster' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
用户地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={asterUser}
|
||||
onChange={(e) => setAsterUser(e.target.value)}
|
||||
placeholder="请输入 Aster 用户地址"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
签名者地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={asterSigner}
|
||||
onChange={(e) => setAsterSigner(e.target.value)}
|
||||
placeholder="请输入 Aster 签名者地址"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
私钥
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={asterPrivateKey}
|
||||
onChange={(e) => setAsterPrivateKey(e.target.value)}
|
||||
placeholder="请输入 Aster 私钥"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -970,21 +1259,14 @@ function ExchangeConfigModal({
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
{editingExchangeId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete(editingExchangeId);
|
||||
}}
|
||||
className="px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedExchangeId || !apiKey.trim() || (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim())}
|
||||
disabled={
|
||||
!selectedExchangeId ||
|
||||
(selectedExchange?.id === 'binance' && (!apiKey.trim() || !secretKey.trim())) ||
|
||||
(selectedExchange?.id === 'hyperliquid' && (!apiKey.trim() || !hyperliquidWalletAddr.trim())) ||
|
||||
(selectedExchange?.id === 'aster' && (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()))
|
||||
}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
|
||||
344
web/src/components/ComparisonChart.tsx
Normal file
344
web/src/components/ComparisonChart.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionTraderData } from '../types';
|
||||
|
||||
interface ComparisonChartProps {
|
||||
traders: CompetitionTraderData[];
|
||||
}
|
||||
|
||||
export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
|
||||
// 生成唯一的key,当traders变化时会触发重新请求
|
||||
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
|
||||
|
||||
const { data: allTraderHistories, isLoading } = useSWR(
|
||||
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
|
||||
async () => {
|
||||
// 并发请求所有trader的历史数据
|
||||
const promises = traders.map(trader =>
|
||||
api.getEquityHistory(trader.trader_id)
|
||||
);
|
||||
return Promise.all(promises);
|
||||
},
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
);
|
||||
|
||||
// 将数据转换为与原格式兼容的结构
|
||||
const traderHistories = useMemo(() => {
|
||||
if (!allTraderHistories) {
|
||||
return traders.map(() => ({ data: undefined }));
|
||||
}
|
||||
return allTraderHistories.map(data => ({ data }));
|
||||
}, [allTraderHistories, traders.length]);
|
||||
|
||||
// 使用useMemo自动处理数据合并,直接使用data对象作为依赖
|
||||
const combinedData = useMemo(() => {
|
||||
// 等待所有数据加载完成
|
||||
const allLoaded = traderHistories.every((h) => h.data);
|
||||
if (!allLoaded) return [];
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Recalculating chart data...`);
|
||||
|
||||
// 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
|
||||
// 收集所有时间戳
|
||||
const timestampMap = new Map<string, {
|
||||
timestamp: string;
|
||||
time: string;
|
||||
traders: Map<string, { pnl_pct: number; equity: number }>;
|
||||
}>();
|
||||
|
||||
traderHistories.forEach((history, index) => {
|
||||
const trader = traders[index];
|
||||
if (!history.data) return;
|
||||
|
||||
console.log(`Trader ${trader.trader_id}: ${history.data.length} data points`);
|
||||
|
||||
history.data.forEach((point: any) => {
|
||||
const ts = point.timestamp;
|
||||
|
||||
if (!timestampMap.has(ts)) {
|
||||
const time = new Date(ts).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
timestampMap.set(ts, {
|
||||
timestamp: ts,
|
||||
time,
|
||||
traders: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
timestampMap.get(ts)!.traders.set(trader.trader_id, {
|
||||
pnl_pct: point.total_pnl_pct,
|
||||
equity: point.total_equity
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间戳排序,转换为数组
|
||||
const combined = Array.from(timestampMap.entries())
|
||||
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
|
||||
.map(([ts, data], index) => {
|
||||
const entry: any = {
|
||||
index: index + 1, // 使用序号代替cycle
|
||||
time: data.time,
|
||||
timestamp: ts
|
||||
};
|
||||
|
||||
traders.forEach((trader) => {
|
||||
const traderData = data.traders.get(trader.trader_id);
|
||||
if (traderData) {
|
||||
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct;
|
||||
entry[`${trader.trader_id}_equity`] = traderData.equity;
|
||||
}
|
||||
});
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
if (combined.length > 0) {
|
||||
const lastPoint = combined[combined.length - 1];
|
||||
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
|
||||
}
|
||||
|
||||
return combined;
|
||||
}, [allTraderHistories, traders]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<div className="text-sm font-semibold">Loading comparison data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (combinedData.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<div className="text-6xl mb-4 opacity-50">📊</div>
|
||||
<div className="text-lg font-semibold mb-2">暂无历史数据</div>
|
||||
<div className="text-sm">运行几个周期后将显示对比曲线</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 限制显示数据点
|
||||
const MAX_DISPLAY_POINTS = 2000;
|
||||
const displayData =
|
||||
combinedData.length > MAX_DISPLAY_POINTS
|
||||
? combinedData.slice(-MAX_DISPLAY_POINTS)
|
||||
: combinedData;
|
||||
|
||||
// 计算Y轴范围
|
||||
const calculateYDomain = () => {
|
||||
const allValues: number[] = [];
|
||||
displayData.forEach((point) => {
|
||||
traders.forEach((trader) => {
|
||||
const value = point[`${trader.trader_id}_pnl_pct`];
|
||||
if (value !== undefined) {
|
||||
allValues.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (allValues.length === 0) return [-5, 5];
|
||||
|
||||
const minVal = Math.min(...allValues);
|
||||
const maxVal = Math.max(...allValues);
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
|
||||
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
|
||||
|
||||
return [
|
||||
Math.floor(minVal - padding),
|
||||
Math.ceil(maxVal + padding)
|
||||
];
|
||||
};
|
||||
|
||||
// Trader颜色配置 - 使用更鲜艳对比度更高的颜色
|
||||
const getTraderColor = (traderId: string) => {
|
||||
const trader = traders.find((t) => t.trader_id === traderId);
|
||||
if (trader?.ai_model === 'qwen') {
|
||||
return '#c084fc'; // purple-400 (更亮)
|
||||
} else {
|
||||
return '#60a5fa'; // blue-400 (更亮)
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义Tooltip - Binance Style
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{data.time} - #{data.index}
|
||||
</div>
|
||||
{traders.map((trader) => {
|
||||
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
|
||||
const equity = data[`${trader.trader_id}_equity`];
|
||||
if (pnlPct === undefined) return null;
|
||||
|
||||
return (
|
||||
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
|
||||
<div
|
||||
className="text-xs font-semibold mb-0.5"
|
||||
style={{ color: getTraderColor(trader.trader_id) }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
|
||||
({equity?.toFixed(2)} USDT)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 计算当前差距
|
||||
const currentGap = displayData.length > 0 ? (() => {
|
||||
const lastPoint = displayData[displayData.length - 1];
|
||||
const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0);
|
||||
return Math.abs(values[0] - values[1]);
|
||||
})() : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<ResponsiveContainer width="100%" height={520}>
|
||||
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
|
||||
<defs>
|
||||
{traders.map((trader) => (
|
||||
<linearGradient
|
||||
key={`gradient-${trader.trader_id}`}
|
||||
id={`gradient-${trader.trader_id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.9} />
|
||||
<stop offset="95%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.2} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
interval={Math.floor(displayData.length / 12)}
|
||||
angle={-15}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
width={60}
|
||||
/>
|
||||
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#474D57"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1.5}
|
||||
label={{
|
||||
value: 'Break Even',
|
||||
fill: '#848E9C',
|
||||
fontSize: 11,
|
||||
position: 'right',
|
||||
}}
|
||||
/>
|
||||
|
||||
{traders.map((trader) => (
|
||||
<Line
|
||||
key={trader.trader_id}
|
||||
type="monotone"
|
||||
dataKey={`${trader.trader_id}_pnl_pct`}
|
||||
stroke={getTraderColor(trader.trader_id)}
|
||||
strokeWidth={3}
|
||||
dot={displayData.length < 50 ? { fill: getTraderColor(trader.trader_id), r: 3 } : false}
|
||||
activeDot={{ r: 6, fill: getTraderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
|
||||
name={trader.trader_name}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="line"
|
||||
formatter={(value, entry: any) => {
|
||||
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
|
||||
const trader = traders.find((t) => t.trader_id === traderId);
|
||||
return (
|
||||
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
|
||||
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-6 grid grid-cols-4 gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
|
||||
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>对比模式</div>
|
||||
<div className="text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
|
||||
</div>
|
||||
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>数据点数</div>
|
||||
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{combinedData.length} 个</div>
|
||||
</div>
|
||||
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>当前差距</div>
|
||||
<div className="text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
|
||||
{currentGap.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
|
||||
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>显示范围</div>
|
||||
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{combinedData.length > MAX_DISPLAY_POINTS
|
||||
? `最近 ${MAX_DISPLAY_POINTS}`
|
||||
: '全部数据'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
web/src/components/CompetitionPage.tsx
Normal file
249
web/src/components/CompetitionPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionData } from '../types';
|
||||
import { ComparisonChart } from './ComparisonChart';
|
||||
|
||||
export function CompetitionPage() {
|
||||
const { data: competition } = useSWR<CompetitionData>(
|
||||
'competition',
|
||||
api.getCompetition,
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(竞赛数据不需要太频繁更新)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!competition || !competition.traders) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="binance-card p-8 animate-pulse">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="skeleton h-8 w-64"></div>
|
||||
<div className="skeleton h-4 w-48"></div>
|
||||
</div>
|
||||
<div className="skeleton h-12 w-32"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="binance-card p-6">
|
||||
<div className="skeleton h-6 w-40 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-20 w-full rounded"></div>
|
||||
<div className="skeleton h-20 w-full rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按收益率排序
|
||||
const sortedTraders = [...competition.traders].sort(
|
||||
(a, b) => b.total_pnl_pct - a.total_pnl_pct
|
||||
);
|
||||
|
||||
// 找出领先者
|
||||
const leader = sortedTraders[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Competition Header - 精简版 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
|
||||
}}>
|
||||
🏆
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
AI竞赛
|
||||
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
{competition.count} 交易员
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
实时对战
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>领先者</div>
|
||||
<div className="text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
|
||||
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left/Right Split: Performance Chart + Leaderboard */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Left: Performance Comparison Chart */}
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
表现对比
|
||||
</h2>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
实时收益率
|
||||
</div>
|
||||
</div>
|
||||
<ComparisonChart traders={sortedTraders} />
|
||||
</div>
|
||||
|
||||
{/* Right: Leaderboard */}
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
排行榜
|
||||
</h2>
|
||||
<div className="text-xs px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
|
||||
实时
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isLeader = index === 0;
|
||||
const aiModelColor = trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px]"
|
||||
style={{
|
||||
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
|
||||
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
|
||||
boxShadow: isLeader ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' : '0 1px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Rank & Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl w-6">
|
||||
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
|
||||
<div className="text-xs mono font-semibold" style={{ color: aiModelColor }}>
|
||||
{trader.ai_model.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Total Equity */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>权益</div>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* P&L */}
|
||||
<div className="text-right min-w-[90px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>收益</div>
|
||||
<div
|
||||
className="text-lg font-bold mono"
|
||||
style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>持仓</div>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
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 ? '●' : '○'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Head-to-Head Stats */}
|
||||
{competition.traders.length === 2 && (
|
||||
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}>
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
正面对决
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sortedTraders.map((trader, index) => {
|
||||
const isWinning = index === 0;
|
||||
const opponent = sortedTraders[1 - index];
|
||||
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
|
||||
style={isWinning
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
|
||||
border: '2px solid rgba(14, 203, 129, 0.3)',
|
||||
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)'
|
||||
}
|
||||
: {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-base font-bold mb-2"
|
||||
style={{ color: trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
{isWinning && gap > 0 && (
|
||||
<div className="text-xs font-semibold" style={{ color: '#0ECB81' }}>
|
||||
领先 {gap.toFixed(2)}%
|
||||
</div>
|
||||
)}
|
||||
{!isWinning && gap < 0 && (
|
||||
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}>
|
||||
落后 {Math.abs(gap).toFixed(2)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
web/src/components/ExchangeIcons.tsx
Normal file
120
web/src/components/ExchangeIcons.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Binance SVG 图标组件
|
||||
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="-52.785 -88 457.47 528"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
|
||||
fill="#f0b90b"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Hyperliquid SVG 图标组件
|
||||
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 144 144"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
|
||||
fill="#97FCE4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Aster SVG 图标组件
|
||||
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
<stop offset="1" stopColor="#FFD29F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#F4D5B1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
|
||||
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
|
||||
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
|
||||
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 获取交易所图标的函数
|
||||
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
|
||||
// 支持完整ID或类型名
|
||||
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' :
|
||||
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' :
|
||||
exchangeType.toLowerCase().includes('aster') ? 'aster' :
|
||||
exchangeType.toLowerCase();
|
||||
|
||||
const iconProps = {
|
||||
width: props.width || 24,
|
||||
height: props.height || 24,
|
||||
className: props.className
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'binance':
|
||||
case 'cex':
|
||||
return <BinanceIcon {...iconProps} />;
|
||||
case 'hyperliquid':
|
||||
case 'dex':
|
||||
return <HyperliquidIcon {...iconProps} />;
|
||||
case 'aster':
|
||||
return <AsterIcon {...iconProps} />;
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
width: props.width || 24,
|
||||
height: props.height || 24,
|
||||
borderRadius: '50%',
|
||||
background: '#2B3139',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: '#EAECEF'
|
||||
}}
|
||||
>
|
||||
{type[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
58
web/src/components/Header.tsx
Normal file
58
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
|
||||
interface HeaderProps {
|
||||
simple?: boolean; // For login/register pages
|
||||
}
|
||||
|
||||
export function Header({ simple = false }: HeaderProps) {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left - Logo and Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xl"
|
||||
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
⚡
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
<p className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
{t('subtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Language Toggle */}
|
||||
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
|
||||
<button
|
||||
onClick={() => setLanguage('zh')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
195
web/src/components/LoginPage.tsx
Normal file
195
web/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { Header } from './Header';
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage();
|
||||
const { login, verifyOTP } = useAuth();
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [userID, setUserID] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.requiresOTP && result.userID) {
|
||||
setUserID(result.userID);
|
||||
setStep('otp');
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('loginFailed', language));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleOTPVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await verifyOTP(userID, otpCode);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('verificationFailed', language));
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
||||
<Header simple />
|
||||
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center text-3xl"
|
||||
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
⚡
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('loginTitle', language)}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
||||
{step === 'login' ? t('loginTitle', language) : t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
{step === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('loginButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('scanQRCodeInstructions', language)}<br />
|
||||
{t('enterOTPCode', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('login')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('verifyOTP', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('noAccount', language)}{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="font-semibold hover:underline"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('registerNow', language)}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
web/src/components/ModelIcons.tsx
Normal file
36
web/src/components/ModelIcons.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
interface IconProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 获取AI模型图标的函数
|
||||
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
// 支持完整ID或类型名
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType;
|
||||
|
||||
let iconPath: string | null = null;
|
||||
|
||||
switch (type) {
|
||||
case 'deepseek':
|
||||
iconPath = '/icons/deepseek.svg';
|
||||
break;
|
||||
case 'qwen':
|
||||
iconPath = '/icons/qwen.svg';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconPath}
|
||||
alt={`${type} icon`}
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
className={props.className}
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
311
web/src/components/RegisterPage.tsx
Normal file
311
web/src/components/RegisterPage.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage();
|
||||
const { register, completeRegistration } = useAuth();
|
||||
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [userID, setUserID] = useState('');
|
||||
const [otpSecret, setOtpSecret] = useState('');
|
||||
const [qrCodeURL, setQrCodeURL] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('passwordMismatch', language));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError(t('passwordTooShort', language));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await register(email, password);
|
||||
|
||||
if (result.success && result.userID) {
|
||||
setUserID(result.userID);
|
||||
setOtpSecret(result.otpSecret || '');
|
||||
setQrCodeURL(result.qrCodeURL || '');
|
||||
setStep('setup-otp');
|
||||
} else {
|
||||
setError(result.message || t('registrationFailed', language));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setStep('verify-otp');
|
||||
};
|
||||
|
||||
const handleOTPVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
const result = await completeRegistration(userID, otpCode);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('registrationFailed', language));
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center text-3xl"
|
||||
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
⚡
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
||||
{step === 'register' && t('registerTitle', language)}
|
||||
{step === 'setup-otp' && t('setupTwoFactor', language)}
|
||||
{step === 'verify-otp' && t('verifyOTP', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
{step === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup-otp' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('setupTwoFactor', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('setupTwoFactorDesc', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('step1Title', language)}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('step1Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('step2Title', language)}
|
||||
</p>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('step2Desc', language)}
|
||||
</p>
|
||||
|
||||
{qrCodeURL && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
||||
alt="QR Code" className="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
|
||||
style={{ background: '#2B3139', color: '#EAECEF' }}>
|
||||
{otpSecret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(otpSecret)}
|
||||
className="px-2 py-1 text-xs rounded"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('copy', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('step3Title', language)}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('step3Desc', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSetupComplete}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('setupCompleteContinue', language)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify-otp' && (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">🔐</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('enterOTPCode', language)}<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('setup-otp')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('completeRegistration', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
{step === 'register' && (
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
已有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="font-semibold hover:underline"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
立即登录
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user