mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 03:21:04 +08:00
Merge branch 'dev' into quote-tweet-link
This commit is contained in:
302
web/src/App.tsx
302
web/src/App.tsx
@@ -2,10 +2,17 @@ import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { api } from './lib/api';
|
||||
import { EquityChart } from './components/EquityChart';
|
||||
import { AITradersPage } from './components/AITradersPage';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { RegisterPage } from './components/RegisterPage';
|
||||
import { CompetitionPage } from './components/CompetitionPage';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import AILearning from './components/AILearning';
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { t, type Language } from './i18n/translations';
|
||||
import { useSystemConfig } from './hooks/useSystemConfig';
|
||||
import { Zap } from 'lucide-react';
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
@@ -15,10 +22,27 @@ import type {
|
||||
TraderInfo,
|
||||
} from './types';
|
||||
|
||||
type Page = 'competition' | 'trader';
|
||||
type Page = 'competition' | 'traders' | 'trader';
|
||||
|
||||
// 获取友好的AI模型名称
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
switch (modelId.toLowerCase()) {
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
case 'qwen':
|
||||
return 'Qwen';
|
||||
case 'claude':
|
||||
return 'Claude';
|
||||
default:
|
||||
return modelId.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const { user, token, logout, isLoading } = useAuth();
|
||||
const { config: systemConfig, loading: configLoading } = useSystemConfig();
|
||||
const [route, setRoute] = useState(window.location.pathname);
|
||||
|
||||
// 从URL hash读取初始页面状态(支持刷新保持页面)
|
||||
const getInitialPage = (): Page => {
|
||||
@@ -45,11 +69,11 @@ function App() {
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
// 切换页面时更新URL hash
|
||||
const navigateToPage = (page: Page) => {
|
||||
setCurrentPage(page);
|
||||
window.location.hash = page === 'competition' ? '' : 'trader';
|
||||
};
|
||||
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage,这个函数暂时保留用于未来扩展)
|
||||
// const navigateToPage = (page: Page) => {
|
||||
// setCurrentPage(page);
|
||||
// window.location.hash = page === 'competition' ? '' : 'trader';
|
||||
// };
|
||||
|
||||
// 获取trader列表
|
||||
const { data: traders } = useSWR<TraderInfo[]>('traders', api.getTraders, {
|
||||
@@ -133,59 +157,120 @@ function App() {
|
||||
|
||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId);
|
||||
|
||||
// Handle routing
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setRoute(window.location.pathname);
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
// Show loading spinner while checking auth or config
|
||||
if (isLoading || configLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
|
||||
<div className="text-center">
|
||||
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 mx-auto mb-4 animate-pulse" />
|
||||
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show landing page for root route when not authenticated
|
||||
if (!systemConfig?.admin_mode && (!user || !token)) {
|
||||
if (route === '/login') {
|
||||
return <LoginPage />;
|
||||
}
|
||||
if (route === '/register') {
|
||||
return <RegisterPage />;
|
||||
}
|
||||
// Default to landing page when not authenticated
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
|
||||
{/* Header - Binance Style */}
|
||||
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
||||
<div className="max-w-[1920px] mx-auto px-3 sm:px-6 py-3 sm:py-4">
|
||||
{/* Mobile: Two rows, Desktop: Single row */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
{/* Left: Logo and Title */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-lg sm:text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
⚡
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-4">
|
||||
<div className="relative flex items-center">
|
||||
{/* Left - Logo and Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
<img src="/icons/nofx.svg?v=2" alt="NOFX" className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base sm:text-xl font-bold leading-tight" style={{ color: '#EAECEF' }}>
|
||||
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
<p className="text-xs mono hidden sm:block" style={{ color: '#848E9C' }}>
|
||||
<p className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
{t('subtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Controls - Wrap on mobile */}
|
||||
<div className="flex items-center gap-2 flex-wrap md:flex-nowrap">
|
||||
{/* GitHub Link - Hidden on mobile, icon only on tablet */}
|
||||
<a
|
||||
href="https://github.com/tinkle-community/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden sm:flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139';
|
||||
e.currentTarget.style.color = '#EAECEF';
|
||||
e.currentTarget.style.borderColor = '#F0B90B';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329';
|
||||
e.currentTarget.style.color = '#848E9C';
|
||||
e.currentTarget.style.borderColor = '#2B3139';
|
||||
}}
|
||||
|
||||
{/* Center - Page Toggle (absolutely positioned) */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
|
||||
<button
|
||||
onClick={() => setCurrentPage('competition')}
|
||||
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
|
||||
style={currentPage === 'competition'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span className="hidden md:inline">GitHub</span>
|
||||
</a>
|
||||
{t('aiCompetition', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage('traders')}
|
||||
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
|
||||
style={currentPage === 'traders'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
{t('aiTraders', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage('trader')}
|
||||
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
|
||||
style={currentPage === 'trader'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
{t('tradingPanel', language)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right - Actions */}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
|
||||
{/* User Info - Only show if not in admin mode */}
|
||||
{!systemConfig?.admin_mode && user && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold" style={{ background: '#F0B90B', color: '#000' }}>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: '#EAECEF' }}>{user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Mode Indicator */}
|
||||
{systemConfig?.admin_mode && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: '#F0B90B' }}>{t('adminMode', language)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}>
|
||||
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
|
||||
<button
|
||||
onClick={() => setLanguage('zh')}
|
||||
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all"
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
@@ -195,7 +280,7 @@ function App() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all"
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
@@ -205,63 +290,15 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Toggle */}
|
||||
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}>
|
||||
{/* Logout Button - Only show if not in admin mode */}
|
||||
{!systemConfig?.admin_mode && (
|
||||
<button
|
||||
onClick={() => navigateToPage('competition')}
|
||||
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
|
||||
style={currentPage === 'competition'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
onClick={logout}
|
||||
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
|
||||
>
|
||||
{t('competition', language)}
|
||||
{t('logout', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToPage('trader')}
|
||||
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
|
||||
style={currentPage === 'trader'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
{t('details', language)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trader Selector (only show on trader page) */}
|
||||
{currentPage === 'trader' && traders && traders.length > 0 && (
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => setSelectedTraderId(e.target.value)}
|
||||
className="rounded px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium cursor-pointer transition-colors flex-1 sm:flex-initial"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id}>
|
||||
{trader.trader_name} ({trader.ai_model.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Status Indicator (only show on trader page) */}
|
||||
{currentPage === 'trader' && status && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded"
|
||||
style={status.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', border: '1px solid rgba(14, 203, 129, 0.2)' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${status.is_running ? 'pulse-glow' : ''}`}
|
||||
style={{ background: status.is_running ? '#0ECB81' : '#F6465D' }}
|
||||
/>
|
||||
<span className="font-semibold mono text-xs">
|
||||
{t(status.is_running ? 'running' : 'stopped', language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,6 +309,13 @@ function App() {
|
||||
<main className="max-w-[1920px] mx-auto px-6 py-6">
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
<AITradersPage
|
||||
onTraderSelect={(traderId) => {
|
||||
setSelectedTraderId(traderId);
|
||||
setCurrentPage('trader');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TraderDetailsPage
|
||||
selectedTrader={selectedTrader}
|
||||
@@ -282,6 +326,9 @@ function App() {
|
||||
stats={stats}
|
||||
lastUpdate={lastUpdate}
|
||||
language={language}
|
||||
traders={traders}
|
||||
selectedTraderId={selectedTraderId}
|
||||
onTraderSelect={setSelectedTraderId}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -291,12 +338,12 @@ function App() {
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm" style={{ color: '#5E6673' }}>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href="https://github.com/tinkle-community/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139';
|
||||
@@ -312,7 +359,7 @@ function App() {
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span>Star on GitHub</span>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,8 +377,14 @@ function TraderDetailsPage({
|
||||
decisions,
|
||||
lastUpdate,
|
||||
language,
|
||||
traders,
|
||||
selectedTraderId,
|
||||
onTraderSelect,
|
||||
}: {
|
||||
selectedTrader?: TraderInfo;
|
||||
traders?: TraderInfo[];
|
||||
selectedTraderId?: string;
|
||||
onTraderSelect: (traderId: string) => void;
|
||||
status?: SystemStatus;
|
||||
account?: AccountInfo;
|
||||
positions?: Position[];
|
||||
@@ -372,14 +425,35 @@ function TraderDetailsPage({
|
||||
<div>
|
||||
{/* Trader Header */}
|
||||
<div className="mb-6 rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}>
|
||||
<h2 className="text-2xl font-bold mb-3 flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
🤖
|
||||
</span>
|
||||
{selectedTrader.trader_name}
|
||||
</h2>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
🤖
|
||||
</span>
|
||||
{selectedTrader.trader_name}
|
||||
</h2>
|
||||
|
||||
{/* Trader Selector */}
|
||||
{traders && traders.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>{t('switchTrader', language)}:</span>
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => onTraderSelect(e.target.value)}
|
||||
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id}>
|
||||
{trader.trader_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm" style={{ color: '#848E9C' }}>
|
||||
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}>{selectedTrader.ai_model.toUpperCase()}</span></span>
|
||||
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa' }}>{getModelDisplayName(selectedTrader.ai_model.split('_').pop() || selectedTrader.ai_model)}</span></span>
|
||||
{status && (
|
||||
<>
|
||||
<span>•</span>
|
||||
@@ -395,9 +469,9 @@ function TraderDetailsPage({
|
||||
{account && (
|
||||
<div className="mb-4 p-3 rounded text-xs font-mono" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div style={{ color: '#848E9C' }}>
|
||||
🔄 Last Update: {lastUpdate} | Total Equity: {account.total_equity?.toFixed(2) || '0.00'} |
|
||||
Available: {account.available_balance?.toFixed(2) || '0.00'} | P&L: {account.total_pnl?.toFixed(2) || '0.00'}{' '}
|
||||
({account.total_pnl_pct?.toFixed(2) || '0.00'}%)
|
||||
🔄 Last Update: {lastUpdate} | Total Equity: {account?.total_equity?.toFixed(2) || '0.00'} |
|
||||
Available: {account?.available_balance?.toFixed(2) || '0.00'} | P&L: {account?.total_pnl?.toFixed(2) || '0.00'}{' '}
|
||||
({account?.total_pnl_pct?.toFixed(2) || '0.00'}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -721,11 +795,13 @@ function DecisionCard({ decision, language }: { decision: DecisionRecord; langua
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap App with LanguageProvider
|
||||
export default function AppWithLanguage() {
|
||||
// Wrap App with providers
|
||||
export default function AppWithProviders() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import useSWR from 'swr';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { api } from '../lib/api';
|
||||
import { Brain, BarChart3, TrendingUp, TrendingDown, Sparkles, Coins, Trophy, ScrollText, Lightbulb } from 'lucide-react';
|
||||
|
||||
interface TradeOutcome {
|
||||
symbol: string;
|
||||
@@ -72,7 +73,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
if (!performance) {
|
||||
return (
|
||||
<div className="rounded p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div style={{ color: '#848E9C' }}>📊 {t('loading', language)}</div>
|
||||
<div className="flex items-center gap-2" style={{ color: '#848E9C' }}>
|
||||
<BarChart3 className="w-4 h-4" /> {t('loading', language)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
return (
|
||||
<div className="rounded p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🧠</span>
|
||||
<Brain className="w-5 h-5" style={{ color: '#8B5CF6' }} />
|
||||
<h2 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('aiLearning', language)}</h2>
|
||||
</div>
|
||||
<div style={{ color: '#848E9C' }}>
|
||||
@@ -109,12 +112,12 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
filter: 'blur(60px)'
|
||||
}} />
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl" style={{
|
||||
<div className="w-16 h-16 rounded-2xl flex items-center justify-center" style={{
|
||||
background: 'linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%)',
|
||||
boxShadow: '0 8px 24px rgba(139, 92, 246, 0.5)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
🧠
|
||||
<Brain className="w-8 h-8" style={{ color: '#FFF' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-1" style={{
|
||||
@@ -149,7 +152,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
<div className="text-4xl font-bold mono mb-1" style={{ color: '#E0E7FF' }}>
|
||||
{performance.total_trades}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#6366F1' }}>📊 Trades</div>
|
||||
<div className="text-xs flex items-center gap-1" style={{ color: '#6366F1' }}>
|
||||
<BarChart3 className="w-3 h-3" /> Trades
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +204,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
<div className="text-4xl font-bold mono mb-1" style={{ color: '#10B981' }}>
|
||||
+{(performance.avg_win || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#6EE7B7' }}>📈 USDT Average</div>
|
||||
<div className="text-xs flex items-center gap-1" style={{ color: '#6EE7B7' }}>
|
||||
<TrendingUp className="w-3 h-3" /> USDT Average
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +227,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
<div className="text-4xl font-bold mono mb-1" style={{ color: '#F87171' }}>
|
||||
{(performance.avg_loss || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#FCA5A5' }}>📉 USDT Average</div>
|
||||
<div className="text-xs flex items-center gap-1" style={{ color: '#FCA5A5' }}>
|
||||
<TrendingDown className="w-3 h-3" /> USDT Average
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,11 +248,11 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
}} />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{
|
||||
background: 'rgba(139, 92, 246, 0.3)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.5)'
|
||||
}}>
|
||||
🧬
|
||||
<Sparkles className="w-6 h-6" style={{ color: '#A78BFA' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold" style={{ color: '#C4B5FD' }}>夏普比率</div>
|
||||
@@ -307,11 +316,11 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
}} />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center" style={{
|
||||
background: 'rgba(240, 185, 11, 0.3)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.5)'
|
||||
}}>
|
||||
💰
|
||||
<Coins className="w-6 h-6" style={{ color: '#FCD34D' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold" style={{ color: '#FCD34D' }}>
|
||||
@@ -373,7 +382,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
boxShadow: '0 4px 16px rgba(16, 185, 129, 0.1)'
|
||||
}}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">🏆</span>
|
||||
<Trophy className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: '#6EE7B7' }}>{t('bestPerformer', language)}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold mono mb-1" style={{ color: '#10B981' }}>
|
||||
@@ -395,7 +404,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
boxShadow: '0 4px 16px rgba(248, 113, 113, 0.1)'
|
||||
}}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-2xl">📉</span>
|
||||
<TrendingDown className="w-6 h-6" style={{ color: '#F87171' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: '#FCA5A5' }}>{t('worstPerformer', language)}</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold mono mb-1" style={{ color: '#F87171' }}>
|
||||
@@ -428,7 +437,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<h3 className="font-bold flex items-center gap-2 text-lg" style={{ color: '#E0E7FF' }}>
|
||||
📊 {t('symbolPerformance', language)}
|
||||
<BarChart3 className="w-5 h-5" /> {t('symbolPerformance', language)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
||||
@@ -488,7 +497,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📜</span>
|
||||
<ScrollText className="w-6 h-6" style={{ color: '#FCD34D' }} />
|
||||
<div>
|
||||
<h3 className="font-bold text-lg" style={{ color: '#FCD34D' }}>{t('tradeHistory', language)}</h3>
|
||||
<p className="text-xs" style={{ color: '#94A3B8' }}>
|
||||
@@ -631,7 +640,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
})
|
||||
) : (
|
||||
<div className="p-6 text-center">
|
||||
<div className="text-4xl mb-2 opacity-50">📜</div>
|
||||
<div className="mb-2 flex justify-center opacity-50">
|
||||
<ScrollText className="w-10 h-10" style={{ color: '#94A3B8' }} />
|
||||
</div>
|
||||
<div style={{ color: '#94A3B8' }}>{t('noCompletedTrades', language)}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -646,11 +657,11 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
boxShadow: '0 4px 16px rgba(240, 185, 11, 0.1)'
|
||||
}}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-xl flex-shrink-0" style={{
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{
|
||||
background: 'rgba(240, 185, 11, 0.2)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)'
|
||||
}}>
|
||||
💡
|
||||
<Lightbulb className="w-5 h-5" style={{ color: '#FCD34D' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold mb-3 text-base" style={{ color: '#FCD34D' }}>{t('howAILearns', language)}</h3>
|
||||
|
||||
1470
web/src/components/AITradersPage.tsx
Normal file
1470
web/src/components/AITradersPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,16 @@ import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionTraderData } from '../types';
|
||||
import { getTraderColor } from '../utils/traderColors';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
interface ComparisonChartProps {
|
||||
traders: CompetitionTraderData[];
|
||||
}
|
||||
|
||||
export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
const { language } = useLanguage();
|
||||
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
|
||||
// 生成唯一的key,当traders变化时会触发重新请求
|
||||
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
|
||||
@@ -116,12 +120,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
if (combined.length > 0) {
|
||||
const lastPoint = combined[combined.length - 1];
|
||||
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
|
||||
console.log('Last 3 points:', combined.slice(-3).map(p => ({
|
||||
time: p.time,
|
||||
timestamp: p.timestamp,
|
||||
deepseek: p.deepseek_trader_pnl_pct,
|
||||
qwen: p.qwen_trader_pnl_pct
|
||||
})));
|
||||
}
|
||||
|
||||
return combined;
|
||||
@@ -139,9 +137,9 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
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>
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
|
||||
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
|
||||
<div className="text-sm">{t('dataWillAppear', language)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -317,25 +315,25 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
{/* 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-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</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 className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div>
|
||||
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: 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-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</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-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div>
|
||||
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
{combinedData.length > MAX_DISPLAY_POINTS
|
||||
? `最近 ${MAX_DISPLAY_POINTS}`
|
||||
: '全部数据'}
|
||||
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
|
||||
: t('allData', language)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { Trophy, Medal } from 'lucide-react';
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import type { CompetitionData } from '../types';
|
||||
import { ComparisonChart } from './ComparisonChart';
|
||||
import { TraderConfigViewModal } from './TraderConfigViewModal';
|
||||
import { getTraderColor } from '../utils/traderColors';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { getTraderColor } from '../utils/traderColors';
|
||||
|
||||
export function CompetitionPage() {
|
||||
const { language } = useLanguage();
|
||||
const [selectedTrader, setSelectedTrader] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const { data: competition } = useSWR<CompetitionData>(
|
||||
'competition',
|
||||
api.getCompetition,
|
||||
@@ -18,6 +24,21 @@ export function CompetitionPage() {
|
||||
}
|
||||
);
|
||||
|
||||
const handleTraderClick = async (traderId: string) => {
|
||||
try {
|
||||
const traderConfig = await api.getTraderConfig(traderId);
|
||||
setSelectedTrader(traderConfig);
|
||||
setIsModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trader config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedTrader(null);
|
||||
};
|
||||
|
||||
if (!competition || !competition.traders) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -54,11 +75,8 @@ export function CompetitionPage() {
|
||||
{/* 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 className="w-12 h-12 rounded-xl flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.15)', border: '1px solid rgba(240,185,11,0.3)' }}>
|
||||
<Trophy className="w-6 h-6" style={{ color: '#F0B90B' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
|
||||
@@ -114,7 +132,8 @@ export function CompetitionPage() {
|
||||
return (
|
||||
<div
|
||||
key={trader.trader_id}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px]"
|
||||
onClick={() => handleTraderClick(trader.trader_id)}
|
||||
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
|
||||
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'}`,
|
||||
@@ -124,13 +143,13 @@ export function CompetitionPage() {
|
||||
<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 className="w-6 flex items-center justify-center">
|
||||
<Medal className="w-5 h-5" style={{ color: index === 0 ? '#F0B90B' : index === 1 ? '#C0C0C0' : '#CD7F32' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
|
||||
<div className="text-xs mono font-semibold" style={{ color: traderColor }}>
|
||||
{trader.ai_model.toUpperCase()}
|
||||
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,11 +242,14 @@ export function CompetitionPage() {
|
||||
>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-base font-bold mb-2"
|
||||
className="text-base font-bold mb-1"
|
||||
style={{ color: getTraderColor(sortedTraders, trader.trader_id) }}
|
||||
>
|
||||
{trader.trader_name}
|
||||
</div>
|
||||
<div className="text-xs mono mb-2" style={{ color: '#848E9C' }}>
|
||||
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
|
||||
</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>
|
||||
@@ -248,6 +270,13 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trader Config View Modal */}
|
||||
<TraderConfigViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
traderData={selectedTrader}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
web/src/components/CryptoFeatureCard.tsx
Normal file
115
web/src/components/CryptoFeatureCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface CryptoFeatureCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureCardProps>(
|
||||
({ icon, title, description, features, className, delay = 0 }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl",
|
||||
"bg-gradient-to-br from-[#0C0E12] to-[#1E2329]",
|
||||
"border-[#2B3139] hover:border-[#F0B90B]/50",
|
||||
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated glow border effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</motion.div>
|
||||
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 p-8 flex flex-col h-full">
|
||||
{/* Icon container */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
"mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl",
|
||||
"bg-gradient-to-br from-[#F0B90B]/20 to-[#F0B90B]/5",
|
||||
"border border-[#F0B90B]/30"
|
||||
)}
|
||||
animate={{
|
||||
scale: isHovered ? 1.1 : 1,
|
||||
boxShadow: isHovered
|
||||
? "0 0 20px rgba(240, 185, 11, 0.4)"
|
||||
: "0 0 0px rgba(240, 185, 11, 0)",
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="text-[#F0B90B]">{icon}</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-bold text-[#EAECEF] mb-3">{title}</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[#848E9C] mb-6 flex-grow leading-relaxed">{description}</p>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: delay + index * 0.1 }}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="w-5 h-5 rounded-full bg-[#F0B90B]/20 flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-[#F0B90B]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-[#EAECEF]">{feature}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CryptoFeatureCard.displayName = "CryptoFeatureCard";
|
||||
@@ -13,6 +13,7 @@ import useSWR from 'swr';
|
||||
import { api } from '../lib/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown } from 'lucide-react'
|
||||
|
||||
interface EquityPoint {
|
||||
timestamp: string;
|
||||
@@ -52,16 +53,26 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="binance-card p-6">
|
||||
<div className="flex items-center gap-3 p-4 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
|
||||
<div className="text-2xl">⚠️</div>
|
||||
<div className='binance-card p-6'>
|
||||
<div
|
||||
className='flex items-center gap-3 p-4 rounded'
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.2)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className='w-6 h-6' style={{ color: '#F6465D' }} />
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#F6465D' }}>{t('loadingError', language)}</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>{error.message}</div>
|
||||
<div className='font-semibold' style={{ color: '#F6465D' }}>
|
||||
{t('loadingError', language)}
|
||||
</div>
|
||||
<div className='text-sm' style={{ color: '#848E9C' }}>
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤掉无效数据:total_equity为0或小于1的数据点(API失败导致)
|
||||
@@ -69,15 +80,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
|
||||
if (!validHistory || validHistory.length === 0) {
|
||||
return (
|
||||
<div className="binance-card p-6">
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>{t('accountEquityCurve', language)}</h3>
|
||||
<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">{t('noHistoricalData', language)}</div>
|
||||
<div className="text-sm">{t('dataWillAppear', language)}</div>
|
||||
<div className='binance-card p-6'>
|
||||
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
<div className='text-center py-16' style={{ color: '#848E9C' }}>
|
||||
<div className='mb-4 flex justify-center opacity-50'>
|
||||
<BarChart3 className='w-16 h-16' />
|
||||
</div>
|
||||
<div className='text-lg font-semibold mb-2'>
|
||||
{t('noHistoricalData', language)}
|
||||
</div>
|
||||
<div className='text-sm'>{t('dataWillAppear', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// 限制显示最近的数据点(性能优化)
|
||||
@@ -161,142 +178,238 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="binance-card p-3 sm:p-5 animate-fade-in">
|
||||
<div className='binance-card p-3 sm:p-5 animate-fade-in'>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base sm:text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>{t('accountEquityCurve', language)}</h3>
|
||||
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
|
||||
<span className="text-2xl sm:text-3xl font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'>
|
||||
<div className='flex-1'>
|
||||
<h3
|
||||
className='text-base sm:text-lg font-bold mb-2'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
<div className='flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4'>
|
||||
<span
|
||||
className='text-2xl sm:text-3xl font-bold mono'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{account?.total_equity.toFixed(2) || '0.00'}
|
||||
<span className="text-base sm:text-lg ml-1" style={{ color: '#848E9C' }}>USDT</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded"
|
||||
className='text-base sm:text-lg ml-1'
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
USDT
|
||||
</span>
|
||||
</span>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<span
|
||||
className='text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1'
|
||||
style={{
|
||||
color: isProfit ? '#0ECB81' : '#F6465D',
|
||||
background: isProfit ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)',
|
||||
border: `1px solid ${isProfit ? 'rgba(14, 203, 129, 0.2)' : 'rgba(246, 70, 93, 0.2)'}`
|
||||
background: isProfit
|
||||
? 'rgba(14, 203, 129, 0.1)'
|
||||
: 'rgba(246, 70, 93, 0.1)',
|
||||
border: `1px solid ${
|
||||
isProfit
|
||||
? 'rgba(14, 203, 129, 0.2)'
|
||||
: 'rgba(246, 70, 93, 0.2)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{isProfit ? '▲' : '▼'} {isProfit ? '+' : ''}
|
||||
{isProfit ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
{isProfit ? '+' : ''}
|
||||
{currentValue.raw_pnl_pct}%
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm mono" style={{ color: '#848E9C' }}>
|
||||
({isProfit ? '+' : ''}{currentValue.raw_pnl.toFixed(2)} USDT)
|
||||
<span
|
||||
className='text-xs sm:text-sm mono'
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
({isProfit ? '+' : ''}
|
||||
{currentValue.raw_pnl.toFixed(2)} USDT)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Mode Toggle */}
|
||||
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div
|
||||
className='flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto'
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDisplayMode('dollar')}
|
||||
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all"
|
||||
style={displayMode === 'dollar'
|
||||
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
|
||||
style={
|
||||
displayMode === 'dollar'
|
||||
? {
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
|
||||
}
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
💵 USDT
|
||||
<DollarSign className='w-4 h-4' /> USDT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDisplayMode('percent')}
|
||||
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all"
|
||||
style={displayMode === 'percent'
|
||||
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
|
||||
style={
|
||||
displayMode === 'percent'
|
||||
? {
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
|
||||
}
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
}
|
||||
>
|
||||
📊 %
|
||||
<Percent className='w-4 h-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="my-2" style={{ borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 5, bottom: 30 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#FCD535" 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(chartData.length / 10)}
|
||||
angle={-15}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) =>
|
||||
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
|
||||
}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={displayMode === 'dollar' ? initialBalance : 0}
|
||||
stroke="#474D57"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: displayMode === 'dollar' ? t('initialBalance', language).split(' ')[0] : '0%',
|
||||
fill: '#848E9C',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="natural"
|
||||
dataKey="value"
|
||||
stroke="url(#colorGradient)"
|
||||
strokeWidth={3}
|
||||
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
|
||||
activeDot={{ r: 6, fill: '#FCD535', stroke: '#F0B90B', strokeWidth: 2 }}
|
||||
connectNulls={true}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<ResponsiveContainer width='100%' height={280}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'>
|
||||
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} />
|
||||
<stop offset='95%' stopColor='#FCD535' 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(chartData.length / 10)}
|
||||
angle={-15}
|
||||
textAnchor='end'
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke='#5E6673'
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) =>
|
||||
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
|
||||
}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={displayMode === 'dollar' ? initialBalance : 0}
|
||||
stroke='#474D57'
|
||||
strokeDasharray='3 3'
|
||||
label={{
|
||||
value:
|
||||
displayMode === 'dollar'
|
||||
? t('initialBalance', language).split(' ')[0]
|
||||
: '0%',
|
||||
fill: '#848E9C',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type='natural'
|
||||
dataKey='value'
|
||||
stroke='url(#colorGradient)'
|
||||
strokeWidth={3}
|
||||
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: '#FCD535',
|
||||
stroke: '#F0B90B',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
connectNulls={true}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3" style={{ borderTop: '1px solid #2B3139' }}>
|
||||
<div className="p-2 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' }}>{t('initialBalance', language)}</div>
|
||||
<div className="text-xs sm:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div
|
||||
className='mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3'
|
||||
style={{ borderTop: '1px solid #2B3139' }}
|
||||
>
|
||||
<div
|
||||
className='p-2 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' }}
|
||||
>
|
||||
{t('initialBalance', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{initialBalance.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 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' }}>{t('currentEquity', language)}</div>
|
||||
<div className="text-xs sm:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div
|
||||
className='p-2 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' }}
|
||||
>
|
||||
{t('currentEquity', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{currentValue.raw_equity.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 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' }}>{t('historicalCycles', language)}</div>
|
||||
<div className="text-xs sm:text-sm font-bold mono" style={{ color: '#EAECEF' }}>{validHistory.length} {t('cycles', language)}</div>
|
||||
<div
|
||||
className='p-2 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' }}
|
||||
>
|
||||
{t('historicalCycles', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{validHistory.length} {t('cycles', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 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' }}>{t('displayRange', language)}</div>
|
||||
<div className="text-xs sm:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div
|
||||
className='p-2 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' }}
|
||||
>
|
||||
{t('displayRange', language)}
|
||||
</div>
|
||||
<div
|
||||
className='text-xs sm:text-sm font-bold mono'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{validHistory.length > MAX_DISPLAY_POINTS
|
||||
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
|
||||
: t('allData', language)
|
||||
}
|
||||
: t('allData', language)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
};
|
||||
59
web/src/components/Header.tsx
Normal file
59
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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="flex items-center justify-center">
|
||||
<img src="/images/logo.png" alt="NoFx Logo" className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
{!simple && (
|
||||
<p className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
{t('subtitle', language)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Language Toggle (always show) */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
208
web/src/components/LoginPage.tsx
Normal file
208
web/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { Header } from './Header';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
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">
|
||||
{/* Back to Home */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 object-contain" />
|
||||
</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%' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
326
web/src/components/RegisterPage.tsx
Normal file
326
web/src/components/RegisterPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { t } from '../i18n/translations';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
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">
|
||||
{/* Back to Home */}
|
||||
{step === 'register' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<img src="/images/logo.png" alt="NoFx Logo" className="w-16 h-16 object-contain" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
494
web/src/components/TraderConfigModal.tsx
Normal file
494
web/src/components/TraderConfigModal.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types';
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
const parts = fullName.split('_');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName;
|
||||
}
|
||||
|
||||
interface TraderConfigData {
|
||||
trader_id?: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id: string;
|
||||
btc_eth_leverage: number;
|
||||
altcoin_leverage: number;
|
||||
trading_symbols: string;
|
||||
custom_prompt: string;
|
||||
override_base_prompt: boolean;
|
||||
system_prompt_template: string;
|
||||
is_cross_margin: boolean;
|
||||
use_coin_pool: boolean;
|
||||
use_oi_top: boolean;
|
||||
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 [formData, setFormData] = useState<TraderConfigData>({
|
||||
trader_name: '',
|
||||
ai_model: '',
|
||||
exchange_id: '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [availableCoins, setAvailableCoins] = useState<string[]>([]);
|
||||
const [selectedCoins, setSelectedCoins] = useState<string[]>([]);
|
||||
const [showCoinSelector, setShowCoinSelector] = useState(false);
|
||||
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (traderData) {
|
||||
setFormData(traderData);
|
||||
// 设置已选择的币种
|
||||
if (traderData.trading_symbols) {
|
||||
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s);
|
||||
setSelectedCoins(coins);
|
||||
}
|
||||
} else if (!isEditMode) {
|
||||
setFormData({
|
||||
trader_name: '',
|
||||
ai_model: availableModels[0]?.id || '',
|
||||
exchange_id: availableExchanges[0]?.id || '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
});
|
||||
}
|
||||
// 确保旧数据也有默认的 system_prompt_template
|
||||
if (traderData && !traderData.system_prompt_template) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
system_prompt_template: 'default'
|
||||
}));
|
||||
}
|
||||
}, [traderData, isEditMode, availableModels, availableExchanges]);
|
||||
|
||||
// 获取系统配置中的币种列表
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const config = await response.json();
|
||||
if (config.default_coins) {
|
||||
setAvailableCoins(config.default_coins);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins(['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT', 'ADAUSDT']);
|
||||
}
|
||||
};
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
// 获取系统提示词模板列表
|
||||
useEffect(() => {
|
||||
const fetchPromptTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/prompt-templates');
|
||||
const data = await response.json();
|
||||
if (data.templates) {
|
||||
setPromptTemplates(data.templates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prompt templates:', error);
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{name: 'default'}, {name: 'aggressive'}]);
|
||||
}
|
||||
};
|
||||
fetchPromptTemplates();
|
||||
}, []);
|
||||
|
||||
// 当选择的币种改变时,更新输入框
|
||||
useEffect(() => {
|
||||
const symbolsString = selectedCoins.join(',');
|
||||
setFormData(prev => ({ ...prev, trading_symbols: symbolsString }));
|
||||
}, [selectedCoins]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// 如果是直接编辑trading_symbols,同步更新selectedCoins
|
||||
if (field === 'trading_symbols') {
|
||||
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s);
|
||||
setSelectedCoins(coins);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoinToggle = (coin: string) => {
|
||||
setSelectedCoins(prev => {
|
||||
if (prev.includes(coin)) {
|
||||
return prev.filter(c => c !== coin);
|
||||
} else {
|
||||
return [...prev, coin];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
btc_eth_leverage: formData.btc_eth_leverage,
|
||||
altcoin_leverage: formData.altcoin_leverage,
|
||||
trading_symbols: formData.trading_symbols,
|
||||
custom_prompt: formData.custom_prompt,
|
||||
override_base_prompt: formData.override_base_prompt,
|
||||
system_prompt_template: formData.system_prompt_template,
|
||||
is_cross_margin: formData.is_cross_margin,
|
||||
use_coin_pool: formData.use_coin_pool,
|
||||
use_oi_top: formData.use_oi_top,
|
||||
initial_balance: formData.initial_balance,
|
||||
};
|
||||
await onSave(saveData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
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]">
|
||||
<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">
|
||||
<span className="text-lg">{isEditMode ? '✏️' : '➕'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#EAECEF]">
|
||||
{isEditMode ? '修改交易员' : '创建交易员'}
|
||||
</h2>
|
||||
<p className="text-sm text-[#848E9C] mt-1">
|
||||
{isEditMode ? '修改交易员配置参数' : '配置新的AI交易员'}
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* 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">
|
||||
🤖 基础配置
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">交易员名称</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模型</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">交易所</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.id).toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Configuration */}
|
||||
<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">
|
||||
⚖️ 交易配置
|
||||
</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">初始余额 ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.initial_balance}
|
||||
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="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:杠杆设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">BTC/ETH 杠杆</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.btc_eth_leverage}
|
||||
onChange={(e) => handleInputChange('btc_eth_leverage', 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="1"
|
||||
max="125"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">山寨币杠杆</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.altcoin_leverage}
|
||||
onChange={(e) => handleInputChange('altcoin_leverage', 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="1"
|
||||
max="75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三行:交易币种 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">交易币种 (用逗号分隔,留空使用默认)</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCoinSelector(!showCoinSelector)}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors"
|
||||
>
|
||||
{showCoinSelector ? '收起选择' : '快速选择'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.trading_symbols}
|
||||
onChange={(e) => handleInputChange('trading_symbols', 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="例如: BTCUSDT,ETHUSDT,ADAUSDT"
|
||||
/>
|
||||
|
||||
{/* 币种选择器 */}
|
||||
{showCoinSelector && (
|
||||
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
|
||||
<div className="text-xs text-[#848E9C] mb-2">点击选择币种:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableCoins.map(coin => (
|
||||
<button
|
||||
key={coin}
|
||||
type="button"
|
||||
onClick={() => handleCoinToggle(coin)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCoins.includes(coin)
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
: 'bg-[#1E2329] text-[#848E9C] border border-[#2B3139] hover:border-[#F0B90B]'
|
||||
}`}
|
||||
>
|
||||
{coin.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal Sources */}
|
||||
<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">
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_coin_pool}
|
||||
onChange={(e) => handleInputChange('use_coin_pool', e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">使用 Coin Pool 信号</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_oi_top}
|
||||
onChange={(e) => handleInputChange('use_oi_top', e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">使用 OI Top 信号</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Prompt */}
|
||||
<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">
|
||||
💬 交易策略提示词
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 系统提示词模板选择 */}
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">系统提示词模板</label>
|
||||
<select
|
||||
value={formData.system_prompt_template}
|
||||
onChange={(e) => handleInputChange('system_prompt_template', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{promptTemplates.map(template => (
|
||||
<option key={template.name} value={template.name}>
|
||||
{template.name === 'default' ? 'Default (默认稳健)' :
|
||||
template.name === 'aggressive' ? 'Aggressive (激进)' :
|
||||
template.name.charAt(0).toUpperCase() + template.name.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
选择预设的交易策略模板(包含交易哲学、风控原则等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.override_base_prompt}
|
||||
onChange={(e) => handleInputChange('override_base_prompt', e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">覆盖默认提示词</label>
|
||||
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg> 启用后将完全替换默认策略</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_prompt}
|
||||
onChange={(e) => handleInputChange('custom_prompt', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
|
||||
placeholder={formData.override_base_prompt ? "输入完整的交易策略提示词..." : "输入额外的交易策略提示..."}
|
||||
/>
|
||||
</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]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
211
web/src/components/TraderConfigViewModal.tsx
Normal file
211
web/src/components/TraderConfigViewModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import type { TraderConfigData } from '../types';
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
const parts = fullName.split('_');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName;
|
||||
}
|
||||
|
||||
|
||||
interface TraderConfigViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
traderData?: TraderConfigData | null;
|
||||
}
|
||||
|
||||
export function TraderConfigViewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
traderData
|
||||
}: TraderConfigViewModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !traderData) return null;
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedField(fieldName);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
|
||||
<button
|
||||
onClick={() => copyToClipboard(text, fieldName)}
|
||||
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
|
||||
style={{
|
||||
background: copiedField === fieldName ? 'rgba(14, 203, 129, 0.1)' : 'rgba(240, 185, 11, 0.1)',
|
||||
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
|
||||
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`
|
||||
}}
|
||||
>
|
||||
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
);
|
||||
|
||||
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
copyable?: boolean;
|
||||
fieldName?: string;
|
||||
}) => (
|
||||
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
|
||||
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
|
||||
<div className="flex items-center text-right">
|
||||
<span className="text-sm text-[#EAECEF] font-mono">
|
||||
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
|
||||
</span>
|
||||
{copyable && typeof value === 'string' && value && (
|
||||
<CopyButton text={value} fieldName={fieldName} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
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]">
|
||||
<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">
|
||||
<span className="text-lg">👁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#EAECEF]">
|
||||
交易员配置
|
||||
</h2>
|
||||
<p className="text-sm text-[#848E9C] mt-1">
|
||||
{traderData.trader_name} 的配置信息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Running Status */}
|
||||
<div
|
||||
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
|
||||
style={traderData.is_running
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
}
|
||||
>
|
||||
<span>{traderData.is_running ? '●' : '○'}</span>
|
||||
{traderData.is_running ? '运行中' : '已停止'}
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
🤖 基础信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" />
|
||||
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" />
|
||||
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} />
|
||||
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} />
|
||||
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Configuration */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
⚖️ 交易配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} />
|
||||
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} />
|
||||
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} />
|
||||
<InfoRow
|
||||
label="交易币种"
|
||||
value={traderData.trading_symbols || '使用默认币种'}
|
||||
copyable
|
||||
fieldName="trading_symbols"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal Sources */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Coin Pool 信号" value={traderData.use_coin_pool} />
|
||||
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] flex items-center gap-2">
|
||||
💬 交易策略提示词
|
||||
</h3>
|
||||
{traderData.custom_prompt && (
|
||||
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} />
|
||||
{traderData.custom_prompt ? (
|
||||
<div>
|
||||
<div className="text-sm text-[#848E9C] mb-2">
|
||||
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'}:
|
||||
</div>
|
||||
<div
|
||||
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{traderData.custom_prompt}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[#848E9C] italic p-3 rounded border" style={{ border: '1px solid #2B3139' }}>
|
||||
未设置自定义提示词,使用系统默认策略
|
||||
</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]">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => copyToClipboard(JSON.stringify(traderData, null, 2), 'full_config')}
|
||||
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
|
||||
>
|
||||
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/src/components/Typewriter.tsx
Normal file
73
web/src/components/Typewriter.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface TypewriterProps {
|
||||
lines: string[]
|
||||
typingSpeed?: number // 毫秒/字符
|
||||
lineDelay?: number // 每行结束的额外等待
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function Typewriter({
|
||||
lines,
|
||||
typingSpeed = 50,
|
||||
lineDelay = 600,
|
||||
className,
|
||||
style,
|
||||
}: TypewriterProps) {
|
||||
const [typedLines, setTypedLines] = useState<string[]>([''])
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const lineIndexRef = useRef(0)
|
||||
const charIndexRef = useRef(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const blinkRef = useRef<number | null>(null)
|
||||
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
|
||||
|
||||
useEffect(() => {
|
||||
function typeNext() {
|
||||
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
||||
if (charIndexRef.current < currentLine.length) {
|
||||
setTypedLines((prev) => {
|
||||
const next = [...prev]
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
next[next.length - 1] = (next[next.length - 1] || '') + ch
|
||||
return next
|
||||
})
|
||||
charIndexRef.current += 1
|
||||
timerRef.current = window.setTimeout(typeNext, typingSpeed)
|
||||
} else {
|
||||
// 行结束
|
||||
if (lineIndexRef.current < sanitizedLines.length - 1) {
|
||||
lineIndexRef.current += 1
|
||||
charIndexRef.current = 0
|
||||
setTypedLines((prev) => [...prev, ''])
|
||||
timerRef.current = window.setTimeout(typeNext, lineDelay)
|
||||
} else {
|
||||
// 最后一行输入完毕
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeNext()
|
||||
|
||||
// 光标闪烁
|
||||
blinkRef.current = window.setInterval(() => {
|
||||
setShowCursor((v) => !v)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
||||
}
|
||||
}, [lines, typingSpeed, lineDelay])
|
||||
|
||||
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
|
||||
|
||||
return (
|
||||
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
|
||||
{displayText}
|
||||
<span style={{ opacity: showCursor ? 1 : 0 }}> ▍</span>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
123
web/src/components/landing/AboutSection.tsx
Normal file
123
web/src/components/landing/AboutSection.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Shield, Target } from 'lucide-react'
|
||||
import AnimatedSection from './AnimatedSection'
|
||||
import Typewriter from '../Typewriter'
|
||||
|
||||
export default function AboutSection() {
|
||||
return (
|
||||
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<div className='grid lg:grid-cols-2 gap-12 items-center'>
|
||||
<motion.div
|
||||
className='space-y-6'
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full'
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Target
|
||||
className='w-4 h-4'
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
<span
|
||||
className='text-sm font-semibold'
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
关于 NOFX
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<h2
|
||||
className='text-4xl font-bold'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
什么是 NOFX?
|
||||
</h2>
|
||||
<p
|
||||
className='text-lg leading-relaxed'
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
NOFX 不是另一个交易机器人,而是 AI 交易的 'Linux' ——
|
||||
一个透明、可信任的开源 OS,提供统一的 '决策-风险-执行'
|
||||
层,支持所有资产类别。
|
||||
</p>
|
||||
<p
|
||||
className='text-lg leading-relaxed'
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI
|
||||
达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR
|
||||
贡献获积分奖励)。
|
||||
</p>
|
||||
<motion.div
|
||||
className='flex items-center gap-3 pt-4'
|
||||
whileHover={{ x: 5 }}
|
||||
>
|
||||
<div
|
||||
className='w-12 h-12 rounded-full flex items-center justify-center'
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
|
||||
>
|
||||
<Shield
|
||||
className='w-6 h-6'
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className='font-semibold'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
你 100% 掌控
|
||||
</div>
|
||||
<div
|
||||
className='text-sm'
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
完全掌控 AI 提示词和资金
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='rounded-2xl p-8'
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<Typewriter
|
||||
lines={[
|
||||
'$ git clone https://github.com/tinkle-community/nofx.git',
|
||||
'$ cd nofx',
|
||||
'$ chmod +x start.sh',
|
||||
'$ ./start.sh start --build',
|
||||
' 启动自动交易系统...',
|
||||
' API服务器启动在端口 8080',
|
||||
' Web 控制台 http://localhost:3000',
|
||||
]}
|
||||
typingSpeed={70}
|
||||
lineDelay={900}
|
||||
className='text-sm font-mono'
|
||||
style={{
|
||||
color: '#00FF41',
|
||||
textShadow: '0 0 6px rgba(0,255,65,0.6)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
export default function AnimatedSection({ children }: { children: React.ReactNode }) {
|
||||
// 轻量容器:统一间距与可读性,避免引入额外依赖
|
||||
return <section className='py-14 md:py-20'>{children}</section>
|
||||
import { useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
|
||||
export default function AnimatedSection({
|
||||
children,
|
||||
id,
|
||||
backgroundColor = 'var(--brand-black)',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
id?: string
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' })
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
id={id}
|
||||
ref={ref}
|
||||
className='py-20 px-4'
|
||||
style={{ background: backgroundColor }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import AnimatedSection from './AnimatedSection'
|
||||
|
||||
type CardProps = {
|
||||
quote: string
|
||||
authorName: string
|
||||
handle: string
|
||||
avatarUrl: string
|
||||
tweetUrl?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function TestimonialCard({ quote, authorName, handle, avatarUrl, tweetUrl, delay = 0 }: CardProps) {
|
||||
function TestimonialCard({ quote, author, delay }: any) {
|
||||
return (
|
||||
<motion.div
|
||||
className='p-6 rounded-xl'
|
||||
@@ -19,32 +10,16 @@ function TestimonialCard({ quote, authorName, handle, avatarUrl, tweetUrl, delay
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<p className='text-lg mb-4 leading-relaxed' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
“{quote}”
|
||||
<p className='text-lg mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
"{quote}"
|
||||
</p>
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* 头像:优先使用传入头像,失败则退回到首字母头像 */}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${authorName} avatar`}
|
||||
className='w-8 h-8 rounded-full object-cover'
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement
|
||||
target.onerror = null
|
||||
target.src = `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(authorName)}`
|
||||
}}
|
||||
/>
|
||||
{tweetUrl ? (
|
||||
<a href={tweetUrl} target='_blank' rel='noopener noreferrer' className='text-sm font-semibold hover:underline' style={{ color: 'var(--text-secondary)' }}>
|
||||
{authorName} ({handle})
|
||||
</a>
|
||||
) : (
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
|
||||
{authorName} ({handle})
|
||||
</span>
|
||||
)}
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
|
||||
{author}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
56
web/src/components/landing/FeaturesSection.tsx
Normal file
56
web/src/components/landing/FeaturesSection.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import AnimatedSection from './AnimatedSection'
|
||||
import { CryptoFeatureCard } from '../CryptoFeatureCard'
|
||||
import { Code, Cpu, Lock, Rocket } from 'lucide-react'
|
||||
|
||||
export default function FeaturesSection() {
|
||||
return (
|
||||
<AnimatedSection id='features'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
核心功能
|
||||
</span>
|
||||
</motion.div>
|
||||
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
为什么选择 NOFX?
|
||||
</h2>
|
||||
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
|
||||
开源、透明、社区驱动的 AI 交易操作系统
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className='grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto'>
|
||||
<CryptoFeatureCard
|
||||
icon={<Code className='w-8 h-8' />}
|
||||
title='100% 开源与自托管'
|
||||
description='你的框架,你的规则。非黑箱,支持自定义提示词和多模型。'
|
||||
features={['完全开源代码', '支持自托管部署', '自定义 AI 提示词', '多模型支持(DeepSeek、Qwen)']}
|
||||
delay={0}
|
||||
/>
|
||||
<CryptoFeatureCard
|
||||
icon={<Cpu className='w-8 h-8' />}
|
||||
title='多代理智能竞争'
|
||||
description='AI 策略在沙盒中高速战斗,最优者生存,实现策略进化。'
|
||||
features={['多 AI 代理并行运行', '策略自动优化', '沙盒安全测试', '跨市场策略移植']}
|
||||
delay={0.1}
|
||||
/>
|
||||
<CryptoFeatureCard
|
||||
icon={<Lock className='w-8 h-8' />}
|
||||
title='安全可靠交易'
|
||||
description='企业级安全保障,完全掌控你的资金和交易策略。'
|
||||
features={['本地私钥管理', 'API 权限精细控制', '实时风险监控', '交易日志审计']}
|
||||
delay={0.2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
)
|
||||
}
|
||||
|
||||
169
web/src/components/landing/FooterSection.tsx
Normal file
169
web/src/components/landing/FooterSection.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export default function FooterSection() {
|
||||
const { language } = useLanguage()
|
||||
return (
|
||||
<footer style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}>
|
||||
<div className='max-w-[1200px] mx-auto px-6 py-10'>
|
||||
{/* Brand */}
|
||||
<div className='flex items-center gap-3 mb-8'>
|
||||
<img src='/images/logo.png' alt='NOFX Logo' className='w-8 h-8' />
|
||||
<div>
|
||||
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}>
|
||||
NOFX
|
||||
</div>
|
||||
<div className='text-xs' style={{ color: '#848E9C' }}>
|
||||
AI 交易的未来标准
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-link columns */}
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8'>
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
链接
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://t.me/nofx_dev_community'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Telegram
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://x.com/nofx_ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
资源
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/blob/main/README.md'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
文档
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/issues'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Issues
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://github.com/tinkle-community/nofx/pulls'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Pull Requests
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className='text-sm font-semibold mb-3'
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
支持方
|
||||
</h3>
|
||||
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://asterdex.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Aster DEX
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://www.binance.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Binance
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://hyperliquid.xyz/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Hyperliquid
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
className='hover:text-[#F0B90B]'
|
||||
href='https://amber.ac/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Amber.ac <span className='opacity-70'>(战略投资)</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom note (kept subtle) */}
|
||||
<div
|
||||
className='pt-6 mt-8 text-center text-xs'
|
||||
style={{ color: '#5E6673', borderTop: '1px solid #2B3139' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className='mt-1'>{t('footerWarning', language)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
97
web/src/components/landing/HeaderBar.tsx
Normal file
97
web/src/components/landing/HeaderBar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
|
||||
export default function HeaderBar({ onLoginClick }: { onLoginClick: () => void }) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<nav className='fixed top-0 w-full z-50 header-bar'>
|
||||
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
{/* Logo */}
|
||||
<div className='flex items-center gap-3'>
|
||||
<img src='/images/logo.png' alt='NOFX Logo' className='w-8 h-8' />
|
||||
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
NOFX
|
||||
</span>
|
||||
<span className='text-sm hidden sm:block' style={{ color: 'var(--text-secondary)' }}>
|
||||
Agentic Trading OS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className='hidden md:flex items-center gap-6'>
|
||||
{['功能', '如何运作', 'GitHub', '社区'].map((item) => (
|
||||
<a
|
||||
key={item}
|
||||
href={
|
||||
item === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item === '社区'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item === '功能' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={item === 'GitHub' || item === '社区' ? '_blank' : undefined}
|
||||
rel={item === 'GitHub' || item === '社区' ? 'noopener noreferrer' : undefined}
|
||||
className='text-sm transition-colors relative group'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item}
|
||||
<span
|
||||
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300'
|
||||
style={{ background: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className='px-4 py-2 rounded font-semibold text-sm'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
>
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className='md:hidden'
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? <X className='w-6 h-6' /> : <Menu className='w-6 h-6' />}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={mobileMenuOpen ? { height: 'auto', opacity: 1 } : { height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className='md:hidden overflow-hidden'
|
||||
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }}
|
||||
>
|
||||
<div className='px-4 py-4 space-y-3'>
|
||||
{['功能', '如何运作', 'GitHub', '社区'].map((item) => (
|
||||
<a key={item} href={`#${item}`} className='block text-sm py-2' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
onLoginClick()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className='w-full px-4 py-2 rounded font-semibold text-sm mt-2'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
>
|
||||
登录 / 注册
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
82
web/src/components/landing/HeroSection.tsx
Normal file
82
web/src/components/landing/HeroSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export default function HeroSection() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
|
||||
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
|
||||
|
||||
const fadeInUp = {
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] },
|
||||
}
|
||||
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
|
||||
|
||||
return (
|
||||
<section className='relative pt-32 pb-20 px-4'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<div className='grid lg:grid-cols-2 gap-12 items-center'>
|
||||
{/* Left Content */}
|
||||
<motion.div className='space-y-6 relative z-10' style={{ opacity, scale }} initial='initial' animate='animate' variants={staggerContainer}>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<motion.div
|
||||
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }}
|
||||
>
|
||||
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||
3 天内 2.5K+ GitHub Stars
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<h1 className='text-5xl lg:text-7xl font-bold leading-tight' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
Read the Market.
|
||||
<br />
|
||||
<span style={{ color: 'var(--brand-yellow)' }}>Write the Trade.</span>
|
||||
</h1>
|
||||
|
||||
<motion.p className='text-xl leading-relaxed' style={{ color: 'var(--text-secondary)' }} variants={fadeInUp}>
|
||||
NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,
|
||||
自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。
|
||||
</motion.p>
|
||||
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<img
|
||||
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
|
||||
alt='GitHub Stars'
|
||||
className='h-7'
|
||||
/>
|
||||
</motion.a>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/network/members' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<img
|
||||
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
|
||||
alt='GitHub Forks'
|
||||
className='h-7'
|
||||
/>
|
||||
</motion.a>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/graphs/contributors' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
|
||||
<img
|
||||
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=1E2329'
|
||||
alt='GitHub Contributors'
|
||||
className='h-7'
|
||||
/>
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}>
|
||||
由 Aster DEX 和 Binance 提供支持,Amber.ac 战略投资。
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Right Visual */}
|
||||
<motion.img src='/images/main.png' alt='NOFX Platform' className='w-full opacity-90' whileHover={{ scale: 1.05, rotate: 5 }} transition={{ type: 'spring', stiffness: 300 }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
74
web/src/components/landing/HowItWorksSection.tsx
Normal file
74
web/src/components/landing/HowItWorksSection.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import AnimatedSection from './AnimatedSection'
|
||||
|
||||
function StepCard({ number, title, description, delay }: any) {
|
||||
return (
|
||||
<motion.div className='flex gap-6 items-start' initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay }} whileHover={{ x: 10 }}>
|
||||
<motion.div
|
||||
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl'
|
||||
style={{ background: 'var(--binance-yellow)', color: 'var(--brand-black)' }}
|
||||
whileHover={{ scale: 1.2, rotate: 360 }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||
>
|
||||
{number}
|
||||
</motion.div>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold mb-2' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HowItWorksSection() {
|
||||
return (
|
||||
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'>
|
||||
<div className='max-w-7xl mx-auto'>
|
||||
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
如何开始使用 NOFX
|
||||
</h2>
|
||||
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
|
||||
四个简单步骤,开启 AI 自动交易之旅
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className='space-y-8'>
|
||||
{[
|
||||
{ number: 1, title: '拉取 GitHub 仓库', description: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。' },
|
||||
{ number: 2, title: '配置环境', description: '前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。' },
|
||||
{ number: 3, title: '部署与运行', description: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。' },
|
||||
{ number: 4, title: '优化与贡献', description: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。' },
|
||||
].map((step, index) => (
|
||||
<StepCard key={step.number} {...step} delay={index * 0.1} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className='mt-12 p-6 rounded-xl flex items-start gap-4'
|
||||
style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
<div className='w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0' style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><path d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z'/><line x1='12' x2='12' y1='9' y2='13'/><line x1='12' x2='12.01' y1='17' y2='17'/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}>
|
||||
重要风险提示
|
||||
</div>
|
||||
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}>
|
||||
dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
)
|
||||
}
|
||||
63
web/src/components/landing/LoginModal.tsx
Normal file
63
web/src/components/landing/LoginModal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export default function LoginModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
className='fixed inset-0 z-50 flex items-center justify-center p-4'
|
||||
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className='relative max-w-md w-full rounded-2xl p-8'
|
||||
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
initial={{ scale: 0.9, y: 50 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 50 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<motion.button onClick={onClose} className='absolute top-4 right-4' style={{ color: 'var(--text-secondary)' }} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
|
||||
<X className='w-6 h-6' />
|
||||
</motion.button>
|
||||
<h2 className='text-2xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }}>
|
||||
访问 NOFX 平台
|
||||
</h2>
|
||||
<p className='text-sm mb-6' style={{ color: 'var(--text-secondary)' }}>
|
||||
请选择登录或注册以访问完整的 AI 交易平台
|
||||
</p>
|
||||
<div className='space-y-3'>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClose()
|
||||
}}
|
||||
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
|
||||
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
|
||||
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
登录
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClose()
|
||||
}}
|
||||
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
|
||||
style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
|
||||
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
注册新账号
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
217
web/src/contexts/AuthContext.tsx
Normal file
217
web/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSystemConfig } from '../lib/config';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
|
||||
register: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
|
||||
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
|
||||
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
|
||||
getSystemConfig()
|
||||
.then(data => {
|
||||
if (data.admin_mode) {
|
||||
// 管理员模式下,模拟admin用户
|
||||
setUser({ id: 'admin', email: 'admin@localhost' });
|
||||
setToken('admin-mode');
|
||||
} else {
|
||||
// 非管理员模式,检查本地存储中是否有token
|
||||
const savedToken = localStorage.getItem('auth_token');
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch system config:', err);
|
||||
// 发生错误时,继续检查本地存储
|
||||
const savedToken = localStorage.getItem('auth_token');
|
||||
const savedUser = localStorage.getItem('auth_user');
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (data.requires_otp) {
|
||||
return {
|
||||
success: true,
|
||||
userID: data.user_id,
|
||||
requiresOTP: true,
|
||||
message: data.message,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '登录失败,请重试' };
|
||||
}
|
||||
|
||||
return { success: false, message: '未知错误' };
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
success: true,
|
||||
userID: data.user_id,
|
||||
otpSecret: data.otp_secret,
|
||||
qrCodeURL: data.qr_code_url,
|
||||
message: data.message,
|
||||
};
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '注册失败,请重试' };
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOTP = async (userID: string, otpCode: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// 登录成功,保存token和用户信息
|
||||
const userInfo = { id: data.user_id, email: data.email };
|
||||
setToken(data.token);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo));
|
||||
|
||||
// 跳转到首页
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
return { success: true, message: data.message };
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: 'OTP验证失败,请重试' };
|
||||
}
|
||||
};
|
||||
|
||||
const completeRegistration = async (userID: string, otpCode: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/complete-registration', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// 注册完成,自动登录
|
||||
const userInfo = { id: data.user_id, email: data.email };
|
||||
setToken(data.token);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo));
|
||||
|
||||
// 跳转到首页
|
||||
window.history.pushState({}, '', '/');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
return { success: true, message: data.message };
|
||||
} else {
|
||||
return { success: false, message: data.error };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '注册完成失败,请重试' };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
register,
|
||||
verifyOTP,
|
||||
completeRegistration,
|
||||
logout,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
29
web/src/hooks/useSystemConfig.ts
Normal file
29
web/src/hooks/useSystemConfig.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSystemConfig, type SystemConfig } from '../lib/config';
|
||||
|
||||
export function useSystemConfig() {
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
getSystemConfig()
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setConfig(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!mounted) return;
|
||||
console.error('Failed to fetch system config:', err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { config, loading, error };
|
||||
}
|
||||
@@ -3,15 +3,21 @@ export type Language = 'en' | 'zh';
|
||||
export const translations = {
|
||||
en: {
|
||||
// Header
|
||||
appTitle: 'AI Trading Competition',
|
||||
subtitle: 'Qwen vs DeepSeek · Real-time',
|
||||
competition: 'Competition',
|
||||
appTitle: 'NOFX',
|
||||
subtitle: 'Multi-AI Model Trading Platform',
|
||||
aiTraders: 'AI Traders',
|
||||
details: 'Details',
|
||||
tradingPanel: 'Trading Panel',
|
||||
competition: 'Competition',
|
||||
running: 'RUNNING',
|
||||
stopped: 'STOPPED',
|
||||
adminMode: 'Admin Mode',
|
||||
logout: 'Logout',
|
||||
switchTrader: 'Switch Trader:',
|
||||
view: 'View',
|
||||
|
||||
// Footer
|
||||
footerTitle: 'NOFX - AI Trading Competition System',
|
||||
footerTitle: 'NOFX - AI Trading System',
|
||||
footerWarning: '⚠️ Trading involves risk. Use at your own discretion.',
|
||||
|
||||
// Stats Cards
|
||||
@@ -63,16 +69,25 @@ export const translations = {
|
||||
recent: 'Recent',
|
||||
allData: 'All Data',
|
||||
cycles: 'Cycles',
|
||||
|
||||
// Comparison Chart
|
||||
comparisonMode: 'Comparison Mode',
|
||||
dataPoints: 'Data Points',
|
||||
currentGap: 'Current Gap',
|
||||
count: '{count} pts',
|
||||
|
||||
// Competition Page
|
||||
aiCompetition: 'AI Competition',
|
||||
traders: 'traders',
|
||||
liveBattle: 'Qwen vs DeepSeek · Live Battle',
|
||||
liveBattle: 'Live Battle',
|
||||
realTimeBattle: 'Real-time Battle',
|
||||
leader: 'Leader',
|
||||
leaderboard: 'Leaderboard',
|
||||
live: 'LIVE',
|
||||
realTime: 'LIVE',
|
||||
performanceComparison: 'Performance Comparison',
|
||||
realTimePnL: 'Real-time PnL %',
|
||||
realTimePnLPercent: 'Real-time PnL %',
|
||||
headToHead: 'Head-to-Head Battle',
|
||||
leadingBy: 'Leading by {gap}%',
|
||||
behindBy: 'Behind by {gap}%',
|
||||
@@ -114,22 +129,212 @@ export const translations = {
|
||||
aiLearningPoint3: 'Optimizes position sizing based on win rate',
|
||||
aiLearningPoint4: 'Avoids repeating past mistakes',
|
||||
|
||||
// AI Traders Management
|
||||
manageAITraders: 'Manage your AI trading bots',
|
||||
aiModels: 'AI Models',
|
||||
exchanges: 'Exchanges',
|
||||
createTrader: 'Create Trader',
|
||||
modelConfiguration: 'Model Configuration',
|
||||
configured: 'Configured',
|
||||
notConfigured: 'Not Configured',
|
||||
currentTraders: 'Current Traders',
|
||||
noTraders: 'No AI Traders',
|
||||
createFirstTrader: 'Create your first AI trader to get started',
|
||||
configureModelsFirst: 'Please configure AI models first',
|
||||
configureExchangesFirst: 'Please configure exchanges first',
|
||||
configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first',
|
||||
modelNotConfigured: 'Selected model is not configured',
|
||||
exchangeNotConfigured: 'Selected exchange is not configured',
|
||||
confirmDeleteTrader: 'Are you sure you want to delete this trader?',
|
||||
status: 'Status',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
createNewTrader: 'Create New AI Trader',
|
||||
selectAIModel: 'Select AI Model',
|
||||
selectExchange: 'Select Exchange',
|
||||
traderName: 'Trader Name',
|
||||
enterTraderName: 'Enter trader name',
|
||||
cancel: 'Cancel',
|
||||
create: 'Create',
|
||||
configureAIModels: 'Configure AI Models',
|
||||
configureExchanges: 'Configure Exchanges',
|
||||
useTestnet: 'Use Testnet',
|
||||
enabled: 'Enabled',
|
||||
save: 'Save',
|
||||
|
||||
// AI Model Configuration
|
||||
officialAPI: 'Official API',
|
||||
customAPI: 'Custom API',
|
||||
apiKey: 'API Key',
|
||||
customAPIURL: 'Custom API URL',
|
||||
enterAPIKey: 'Enter API Key',
|
||||
enterCustomAPIURL: 'Enter custom API endpoint URL',
|
||||
useOfficialAPI: 'Use official API service',
|
||||
useCustomAPI: 'Use custom API endpoint',
|
||||
|
||||
// Exchange Configuration
|
||||
secretKey: 'Secret Key',
|
||||
privateKey: 'Private Key',
|
||||
walletAddress: 'Wallet Address',
|
||||
user: 'User',
|
||||
signer: 'Signer',
|
||||
passphrase: 'Passphrase',
|
||||
enterPrivateKey: 'Enter Private Key',
|
||||
enterWalletAddress: 'Enter Wallet Address',
|
||||
enterUser: 'Enter User',
|
||||
enterSigner: 'Enter Signer Address',
|
||||
enterSecretKey: 'Enter Secret Key',
|
||||
enterPassphrase: 'Enter Passphrase (Required for OKX)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication',
|
||||
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key',
|
||||
testnetDescription: 'Enable to connect to exchange test environment for simulated trading',
|
||||
securityWarning: 'Security Warning',
|
||||
saveConfiguration: 'Save Configuration',
|
||||
|
||||
// Trader Configuration
|
||||
positionMode: 'Position Mode',
|
||||
crossMarginMode: 'Cross Margin',
|
||||
isolatedMarginMode: 'Isolated Margin',
|
||||
crossMarginDescription: 'Cross margin: All positions share account balance as collateral',
|
||||
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation',
|
||||
leverageConfiguration: 'Leverage Configuration',
|
||||
btcEthLeverage: 'BTC/ETH Leverage',
|
||||
altcoinLeverage: 'Altcoin Leverage',
|
||||
leverageRecommendation: 'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
|
||||
tradingSymbols: 'Trading Symbols',
|
||||
tradingSymbolsPlaceholder: 'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
selectSymbols: 'Select Symbols',
|
||||
selectTradingSymbols: 'Select Trading Symbols',
|
||||
selectedSymbolsCount: 'Selected {count} symbols',
|
||||
clearSelection: 'Clear All',
|
||||
confirmSelection: 'Confirm',
|
||||
tradingSymbolsDescription: 'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
|
||||
btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
|
||||
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
|
||||
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
|
||||
|
||||
// Loading & Error
|
||||
loading: 'Loading...',
|
||||
loadingError: '⚠️ Failed to load AI learning data',
|
||||
noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
|
||||
|
||||
// AI Traders Page - Additional
|
||||
inUse: 'In Use',
|
||||
noModelsConfigured: 'No configured AI models',
|
||||
noExchangesConfigured: 'No configured exchanges',
|
||||
signalSource: 'Signal Source',
|
||||
signalSourceConfig: 'Signal Source Configuration',
|
||||
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source',
|
||||
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source',
|
||||
information: 'Information',
|
||||
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs',
|
||||
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources',
|
||||
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals',
|
||||
editAIModel: 'Edit AI Model',
|
||||
addAIModel: 'Add AI Model',
|
||||
confirmDeleteModel: 'Are you sure you want to delete this AI model configuration?',
|
||||
selectModel: 'Select AI Model',
|
||||
pleaseSelectModel: 'Please select a model',
|
||||
customBaseURL: 'Base URL (Optional)',
|
||||
customBaseURLPlaceholder: 'Custom API base URL, e.g.: https://api.openai.com/v1',
|
||||
leaveBlankForDefault: 'Leave blank to use default API address',
|
||||
modelConfigInfo1: '• API Key will be encrypted and stored, please ensure it is valid',
|
||||
modelConfigInfo2: '• Base URL is used for custom API server address',
|
||||
modelConfigInfo3: '• After deleting configuration, traders using this model will not work properly',
|
||||
saveConfig: 'Save Configuration',
|
||||
editExchange: 'Edit Exchange',
|
||||
addExchange: 'Add Exchange',
|
||||
confirmDeleteExchange: 'Are you sure you want to delete this exchange configuration?',
|
||||
pleaseSelectExchange: 'Please select an exchange',
|
||||
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions',
|
||||
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security',
|
||||
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade',
|
||||
edit: 'Edit',
|
||||
|
||||
// Error Messages
|
||||
createTraderFailed: 'Failed to create trader',
|
||||
getTraderConfigFailed: 'Failed to get trader configuration',
|
||||
modelConfigNotExist: 'Model configuration does not exist or is not enabled',
|
||||
exchangeConfigNotExist: 'Exchange configuration does not exist or is not enabled',
|
||||
updateTraderFailed: 'Failed to update trader',
|
||||
deleteTraderFailed: 'Failed to delete trader',
|
||||
operationFailed: 'Operation failed',
|
||||
deleteConfigFailed: 'Failed to delete configuration',
|
||||
modelNotExist: 'Model does not exist',
|
||||
saveConfigFailed: 'Failed to save configuration',
|
||||
exchangeNotExist: 'Exchange does not exist',
|
||||
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
|
||||
saveSignalSourceFailed: 'Failed to save signal source configuration',
|
||||
|
||||
// Login & Register
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
confirmPasswordPlaceholder: 'Re-enter your password',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: 'Sign in to your account',
|
||||
registerTitle: 'Create a new account',
|
||||
loginButton: 'Sign In',
|
||||
registerButton: 'Sign Up',
|
||||
back: 'Back',
|
||||
noAccount: "Don't have an account?",
|
||||
hasAccount: 'Already have an account?',
|
||||
registerNow: 'Sign up now',
|
||||
loginNow: 'Sign in now',
|
||||
forgotPassword: 'Forgot password?',
|
||||
rememberMe: 'Remember me',
|
||||
otpCode: 'OTP Code',
|
||||
scanQRCode: 'Scan QR Code',
|
||||
enterOTPCode: 'Enter 6-digit OTP code',
|
||||
verifyOTP: 'Verify OTP',
|
||||
setupTwoFactor: 'Set up two-factor authentication',
|
||||
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
|
||||
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
|
||||
otpSecret: 'Or enter this secret manually:',
|
||||
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
|
||||
step1Title: 'Step 1: Install Google Authenticator',
|
||||
step1Desc: 'Download and install Google Authenticator from your app store',
|
||||
step2Title: 'Step 2: Add account',
|
||||
step2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
|
||||
step3Title: 'Step 3: Verify setup',
|
||||
step3Desc: 'After setup, continue to enter the 6-digit code',
|
||||
setupCompleteContinue: 'I have completed setup, continue',
|
||||
copy: 'Copy',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completeRegistrationSubtitle: 'to complete registration',
|
||||
loginSuccess: 'Login successful',
|
||||
registrationSuccess: 'Registration successful',
|
||||
loginFailed: 'Login failed',
|
||||
registrationFailed: 'Registration failed',
|
||||
verificationFailed: 'OTP verification failed',
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
invalidEmail: 'Invalid email format',
|
||||
passwordTooShort: 'Password must be at least 6 characters',
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
appTitle: 'AI交易竞赛',
|
||||
subtitle: 'Qwen vs DeepSeek · 实时',
|
||||
competition: '竞赛',
|
||||
appTitle: 'NOFX',
|
||||
subtitle: '多AI模型交易平台',
|
||||
aiTraders: 'AI交易员',
|
||||
details: '详情',
|
||||
tradingPanel: '交易面板',
|
||||
competition: '竞赛',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
adminMode: '管理员模式',
|
||||
logout: '退出',
|
||||
switchTrader: '切换交易员:',
|
||||
view: '查看',
|
||||
|
||||
// Footer
|
||||
footerTitle: 'NOFX - AI交易竞赛系统',
|
||||
footerTitle: 'NOFX - AI交易系统',
|
||||
footerWarning: '⚠️ 交易有风险,请谨慎使用。',
|
||||
|
||||
// Stats Cards
|
||||
@@ -181,22 +386,31 @@ export const translations = {
|
||||
recent: '最近',
|
||||
allData: '全部数据',
|
||||
cycles: '个',
|
||||
|
||||
// Comparison Chart
|
||||
comparisonMode: '对比模式',
|
||||
dataPoints: '数据点数',
|
||||
currentGap: '当前差距',
|
||||
count: '{count} 个',
|
||||
|
||||
// Competition Page
|
||||
aiCompetition: 'AI竞赛',
|
||||
traders: '位交易者',
|
||||
liveBattle: 'Qwen vs DeepSeek · 实时对战',
|
||||
leader: '🥇 领先者',
|
||||
leaderboard: '🥇 排行榜',
|
||||
live: '直播',
|
||||
performanceComparison: '📈 表现对比',
|
||||
realTimePnL: '实时盈亏百分比',
|
||||
headToHead: '⚔️ 正面对决',
|
||||
traders: '交易员',
|
||||
liveBattle: '实时对战',
|
||||
realTimeBattle: '实时对战',
|
||||
leader: '领先者',
|
||||
leaderboard: '排行榜',
|
||||
live: '实时',
|
||||
realTime: '实时',
|
||||
performanceComparison: '表现对比',
|
||||
realTimePnL: '实时收益率',
|
||||
realTimePnLPercent: '实时收益率',
|
||||
headToHead: '正面对决',
|
||||
leadingBy: '领先 {gap}%',
|
||||
behindBy: '落后 {gap}%',
|
||||
equity: '净值',
|
||||
pnl: '盈亏',
|
||||
pos: '仓位',
|
||||
equity: '权益',
|
||||
pnl: '收益',
|
||||
pos: '持仓',
|
||||
|
||||
// AI Learning
|
||||
aiLearning: 'AI学习与反思',
|
||||
@@ -232,10 +446,194 @@ export const translations = {
|
||||
aiLearningPoint3: '根据胜率优化仓位大小',
|
||||
aiLearningPoint4: '避免重复过去的错误',
|
||||
|
||||
// AI Traders Management
|
||||
manageAITraders: '管理您的AI交易机器人',
|
||||
aiModels: 'AI模型',
|
||||
exchanges: '交易所',
|
||||
createTrader: '创建交易员',
|
||||
modelConfiguration: '模型配置',
|
||||
configured: '已配置',
|
||||
notConfigured: '未配置',
|
||||
currentTraders: '当前交易员',
|
||||
noTraders: '暂无AI交易员',
|
||||
createFirstTrader: '创建您的第一个AI交易员开始使用',
|
||||
configureModelsFirst: '请先配置AI模型',
|
||||
configureExchangesFirst: '请先配置交易所',
|
||||
configureModelsAndExchangesFirst: '请先配置AI模型和交易所',
|
||||
modelNotConfigured: '所选模型未配置',
|
||||
exchangeNotConfigured: '所选交易所未配置',
|
||||
confirmDeleteTrader: '确定要删除这个交易员吗?',
|
||||
status: '状态',
|
||||
start: '启动',
|
||||
stop: '停止',
|
||||
createNewTrader: '创建新的AI交易员',
|
||||
selectAIModel: '选择AI模型',
|
||||
selectExchange: '选择交易所',
|
||||
traderName: '交易员名称',
|
||||
enterTraderName: '输入交易员名称',
|
||||
cancel: '取消',
|
||||
create: '创建',
|
||||
configureAIModels: '配置AI模型',
|
||||
configureExchanges: '配置交易所',
|
||||
useTestnet: '使用测试网',
|
||||
enabled: '启用',
|
||||
save: '保存',
|
||||
|
||||
// AI Model Configuration
|
||||
officialAPI: '官方API',
|
||||
customAPI: '自定义API',
|
||||
apiKey: 'API密钥',
|
||||
customAPIURL: '自定义API地址',
|
||||
enterAPIKey: '请输入API密钥',
|
||||
enterCustomAPIURL: '请输入自定义API端点地址',
|
||||
useOfficialAPI: '使用官方API服务',
|
||||
useCustomAPI: '使用自定义API端点',
|
||||
|
||||
// Exchange Configuration
|
||||
secretKey: '密钥',
|
||||
privateKey: '私钥',
|
||||
walletAddress: '钱包地址',
|
||||
user: '用户名',
|
||||
signer: '签名者',
|
||||
passphrase: '口令',
|
||||
enterSecretKey: '输入密钥',
|
||||
enterPrivateKey: '输入私钥',
|
||||
enterWalletAddress: '输入钱包地址',
|
||||
enterUser: '输入用户名',
|
||||
enterSigner: '输入签名者地址',
|
||||
enterPassphrase: '输入Passphrase (OKX必填)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
|
||||
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
securityWarning: '安全提示',
|
||||
saveConfiguration: '保存配置',
|
||||
|
||||
// Trader Configuration
|
||||
positionMode: '仓位模式',
|
||||
crossMarginMode: '全仓模式',
|
||||
isolatedMarginMode: '逐仓模式',
|
||||
crossMarginDescription: '全仓模式:所有仓位共享账户余额作为保证金',
|
||||
isolatedMarginDescription: '逐仓模式:每个仓位独立管理保证金,风险隔离',
|
||||
leverageConfiguration: '杠杆配置',
|
||||
btcEthLeverage: 'BTC/ETH杠杆',
|
||||
altcoinLeverage: '山寨币杠杆',
|
||||
leverageRecommendation: '推荐:BTC/ETH 5-10倍,山寨币 3-5倍,控制风险',
|
||||
tradingSymbols: '交易币种',
|
||||
tradingSymbolsPlaceholder: '输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT)',
|
||||
selectSymbols: '选择币种',
|
||||
selectTradingSymbols: '选择交易币种',
|
||||
selectedSymbolsCount: '已选择 {count} 个币种',
|
||||
clearSelection: '清空选择',
|
||||
confirmSelection: '确认选择',
|
||||
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT)',
|
||||
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
|
||||
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
|
||||
invalidSymbolFormat: '无效的币种格式:{symbol},必须以USDT结尾',
|
||||
|
||||
// Loading & Error
|
||||
loading: '加载中...',
|
||||
loadingError: '⚠️ 加载AI学习数据失败',
|
||||
noCompleteData: '暂无完整交易数据(需要完成开仓→平仓的完整周期)',
|
||||
|
||||
// AI Traders Page - Additional
|
||||
inUse: '正在使用',
|
||||
noModelsConfigured: '暂无已配置的AI模型',
|
||||
noExchangesConfigured: '暂无已配置的交易所',
|
||||
signalSource: '信号源',
|
||||
signalSourceConfig: '信号源配置',
|
||||
coinPoolDescription: '用于获取币种池数据的API地址,留空则不使用此信号源',
|
||||
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
||||
information: '说明',
|
||||
signalSourceInfo1: '• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
|
||||
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
|
||||
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
|
||||
editAIModel: '编辑AI模型',
|
||||
addAIModel: '添加AI模型',
|
||||
confirmDeleteModel: '确定要删除此AI模型配置吗?',
|
||||
selectModel: '选择AI模型',
|
||||
pleaseSelectModel: '请选择模型',
|
||||
customBaseURL: 'Base URL (可选)',
|
||||
customBaseURLPlaceholder: '自定义API基础URL,如: https://api.openai.com/v1',
|
||||
leaveBlankForDefault: '留空则使用默认API地址',
|
||||
modelConfigInfo1: '• API Key将被加密存储,请确保密钥有效',
|
||||
modelConfigInfo2: '• Base URL用于自定义API服务器地址',
|
||||
modelConfigInfo3: '• 删除配置后,使用此模型的交易员将无法正常工作',
|
||||
saveConfig: '保存配置',
|
||||
editExchange: '编辑交易所',
|
||||
addExchange: '添加交易所',
|
||||
confirmDeleteExchange: '确定要删除此交易所配置吗?',
|
||||
pleaseSelectExchange: '请选择交易所',
|
||||
exchangeConfigWarning1: '• API密钥将被加密存储,建议使用只读或期货交易权限',
|
||||
exchangeConfigWarning2: '• 不要授予提现权限,确保资金安全',
|
||||
exchangeConfigWarning3: '• 删除配置后,相关交易员将无法正常交易',
|
||||
edit: '编辑',
|
||||
|
||||
// Error Messages
|
||||
createTraderFailed: '创建交易员失败',
|
||||
getTraderConfigFailed: '获取交易员配置失败',
|
||||
modelConfigNotExist: 'AI模型配置不存在或未启用',
|
||||
exchangeConfigNotExist: '交易所配置不存在或未启用',
|
||||
updateTraderFailed: '更新交易员失败',
|
||||
deleteTraderFailed: '删除交易员失败',
|
||||
operationFailed: '操作失败',
|
||||
deleteConfigFailed: '删除配置失败',
|
||||
modelNotExist: '模型不存在',
|
||||
saveConfigFailed: '保存配置失败',
|
||||
exchangeNotExist: '交易所不存在',
|
||||
deleteExchangeConfigFailed: '删除交易所配置失败',
|
||||
saveSignalSourceFailed: '保存信号源配置失败',
|
||||
|
||||
// Login & Register
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
emailPlaceholder: '请输入邮箱地址',
|
||||
passwordPlaceholder: '请输入密码(至少6位)',
|
||||
confirmPasswordPlaceholder: '请再次输入密码',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: '登录到您的账户',
|
||||
registerTitle: '创建新账户',
|
||||
loginButton: '登录',
|
||||
registerButton: '注册',
|
||||
back: '返回',
|
||||
noAccount: '还没有账户?',
|
||||
hasAccount: '已有账户?',
|
||||
registerNow: '立即注册',
|
||||
loginNow: '立即登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
rememberMe: '记住我',
|
||||
otpCode: 'OTP验证码',
|
||||
scanQRCode: '扫描二维码',
|
||||
enterOTPCode: '输入6位OTP验证码',
|
||||
verifyOTP: '验证OTP',
|
||||
setupTwoFactor: '设置双因素认证',
|
||||
setupTwoFactorDesc: '请按以下步骤设置Google验证器以保护您的账户安全',
|
||||
scanQRCodeInstructions: '使用Google Authenticator或Authy扫描此二维码',
|
||||
otpSecret: '或手动输入此密钥:',
|
||||
qrCodeHint: '二维码(如果无法扫描,请使用下方密钥):',
|
||||
step1Title: '步骤1:下载Google Authenticator',
|
||||
step1Desc: '在手机应用商店下载并安装Google Authenticator应用',
|
||||
step2Title: '步骤2:添加账户',
|
||||
step2Desc: '在应用中点击“+”,选择“扫描二维码”或“手动输入密钥”',
|
||||
step3Title: '步骤3:验证设置',
|
||||
step3Desc: '设置完成后,点击下方按钮输入6位验证码',
|
||||
setupCompleteContinue: '我已完成设置,继续',
|
||||
copy: '复制',
|
||||
completeRegistration: '完成注册',
|
||||
completeRegistrationSubtitle: '以完成注册',
|
||||
loginSuccess: '登录成功',
|
||||
registrationSuccess: '注册成功',
|
||||
loginFailed: '登录失败',
|
||||
registrationFailed: '注册失败',
|
||||
verificationFailed: 'OTP验证失败',
|
||||
invalidCredentials: '邮箱或密码错误',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
emailRequired: '请输入邮箱',
|
||||
passwordRequired: '请输入密码',
|
||||
invalidEmail: '邮箱格式不正确',
|
||||
passwordTooShort: '密码至少需要6个字符',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,22 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Binance Brand Colors */
|
||||
--brand-yellow: #F0B90B;
|
||||
--brand-black: #0C0E12;
|
||||
--brand-dark-gray: #1E2329;
|
||||
--brand-light-gray: #EAECEF;
|
||||
--brand-almost-white: #FAFAFA;
|
||||
--brand-white: #FFFFFF;
|
||||
|
||||
/* Binance Theme Colors */
|
||||
--binance-yellow: #F0B90B;
|
||||
--binance-yellow-dark: #C99400;
|
||||
--binance-yellow-light: #FCD535;
|
||||
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
|
||||
|
||||
--background: #0B0E11;
|
||||
--background: #181A20; /* Binance body bg */
|
||||
--header-bg: #0B0E11; /* Binance header bg */
|
||||
--background-elevated: #181A20;
|
||||
--foreground: #EAECEF;
|
||||
--panel-bg: #1E2329;
|
||||
@@ -138,10 +147,10 @@ body {
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,12 +180,9 @@ body {
|
||||
}
|
||||
|
||||
/* Glass effect - Binance header style */
|
||||
.glass {
|
||||
background: var(--background-elevated);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
.header-bar {
|
||||
background: var(--header-bg);
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Monospace numbers */
|
||||
|
||||
@@ -5,31 +5,159 @@ import type {
|
||||
DecisionRecord,
|
||||
Statistics,
|
||||
TraderInfo,
|
||||
AIModel,
|
||||
Exchange,
|
||||
CreateTraderRequest,
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 竞赛相关接口
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const res = await fetch(`${API_BASE}/competition`);
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败');
|
||||
// AI交易员管理接口
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/traders`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取trader列表失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/traders`);
|
||||
if (!res.ok) throw new Error('获取trader列表失败');
|
||||
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('创建交易员失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('删除交易员失败');
|
||||
},
|
||||
|
||||
async startTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('启动交易员失败');
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('停止交易员失败');
|
||||
},
|
||||
|
||||
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ custom_prompt: customPrompt }),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新自定义策略失败');
|
||||
},
|
||||
|
||||
async getTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取交易员配置失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateTrader(traderId: string, request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易员失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// AI模型配置接口
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取模型配置失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
async getSupportedModels(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-models`);
|
||||
if (!res.ok) throw new Error('获取支持的模型失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新模型配置失败');
|
||||
},
|
||||
|
||||
// 交易所配置接口
|
||||
async getExchangeConfigs(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取交易所配置失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// 获取系统支持的交易所列表(无需认证)
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-exchanges`);
|
||||
if (!res.ok) throw new Error('获取支持的交易所失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('更新交易所配置失败');
|
||||
},
|
||||
|
||||
// 获取系统状态(支持trader_id)
|
||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取系统状态失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -42,6 +170,7 @@ export const api = {
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
@@ -56,7 +185,9 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取持仓列表失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -66,7 +197,9 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取决策日志失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -76,7 +209,9 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions/latest`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取最新决策失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -86,7 +221,9 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取统计信息失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -96,7 +233,9 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取历史数据失败');
|
||||
return res.json();
|
||||
},
|
||||
@@ -106,8 +245,40 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/performance?trader_id=${traderId}`
|
||||
: `${API_BASE}/performance`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取AI学习数据失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// 获取竞赛数据
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const res = await fetch(`${API_BASE}/competition`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// 用户信号源配置接口
|
||||
async getUserSignalSource(): Promise<{coin_pool_url: string, oi_top_url: string}> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取用户信号源配置失败');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async saveUserSignalSource(coinPoolUrl: string, oiTopUrl: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
coin_pool_url: coinPoolUrl,
|
||||
oi_top_url: oiTopUrl,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败');
|
||||
},
|
||||
};
|
||||
|
||||
27
web/src/lib/config.ts
Normal file
27
web/src/lib/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface SystemConfig {
|
||||
admin_mode: boolean;
|
||||
}
|
||||
|
||||
let configPromise: Promise<SystemConfig> | null = null;
|
||||
let cachedConfig: SystemConfig | null = null;
|
||||
|
||||
export function getSystemConfig(): Promise<SystemConfig> {
|
||||
if (cachedConfig) {
|
||||
return Promise.resolve(cachedConfig);
|
||||
}
|
||||
if (configPromise) {
|
||||
return configPromise;
|
||||
}
|
||||
configPromise = fetch('/api/config')
|
||||
.then((res) => res.json())
|
||||
.then((data: SystemConfig) => {
|
||||
cachedConfig = data;
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
|
||||
});
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
|
||||
6
web/src/lib/utils.ts
Normal file
6
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
52
web/src/pages/LandingPage.tsx
Normal file
52
web/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import HeaderBar from '../components/landing/HeaderBar'
|
||||
import HeroSection from '../components/landing/HeroSection'
|
||||
import AboutSection from '../components/landing/AboutSection'
|
||||
import FeaturesSection from '../components/landing/FeaturesSection'
|
||||
import HowItWorksSection from '../components/landing/HowItWorksSection'
|
||||
import CommunitySection from '../components/landing/CommunitySection'
|
||||
import AnimatedSection from '../components/landing/AnimatedSection'
|
||||
import LoginModal from '../components/landing/LoginModal'
|
||||
import FooterSection from '../components/landing/FooterSection'
|
||||
|
||||
export function LandingPage() {
|
||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||
return (
|
||||
<div className='min-h-screen overflow-hidden' style={{ background: 'var(--brand-black)', color: 'var(--brand-light-gray)' }}>
|
||||
<HeaderBar onLoginClick={() => setShowLoginModal(true)} />
|
||||
<HeroSection />
|
||||
<AboutSection />
|
||||
<FeaturesSection />
|
||||
<HowItWorksSection />
|
||||
<CommunitySection />
|
||||
|
||||
{/* CTA */}
|
||||
<AnimatedSection backgroundColor='var(--panel-bg)'>
|
||||
<div className='max-w-4xl mx-auto text-center'>
|
||||
<motion.h2 className='text-5xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||
准备好定义 AI 交易的未来吗?
|
||||
</motion.h2>
|
||||
<motion.p className='text-xl mb-12' style={{ color: 'var(--text-secondary)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.1 }}>
|
||||
从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。
|
||||
</motion.p>
|
||||
<div className='flex flex-wrap justify-center gap-4'>
|
||||
<motion.button onClick={() => setShowLoginModal(true)} className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
立即开始
|
||||
<motion.div animate={{ x: [0, 5, 0] }} transition={{ duration: 1.5, repeat: Infinity }}>
|
||||
<ArrowRight className='w-5 h-5' />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
<motion.a href='https://github.com/tinkle-community/nofx/tree/dev' target='_blank' rel='noopener noreferrer' className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
查看源码
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} />}
|
||||
<FooterSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -84,23 +84,97 @@ export interface Statistics {
|
||||
total_close_positions: number;
|
||||
}
|
||||
|
||||
// 新增:竞赛相关类型
|
||||
// AI Trading相关类型
|
||||
export interface TraderInfo {
|
||||
trader_id: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id?: string;
|
||||
is_running?: boolean;
|
||||
custom_prompt?: string;
|
||||
}
|
||||
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
customApiUrl?: string;
|
||||
customModelName?: string;
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'cex' | 'dex';
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
secretKey?: string;
|
||||
testnet?: boolean;
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquidWalletAddr?: string;
|
||||
// Aster 特定字段
|
||||
asterUser?: string;
|
||||
asterSigner?: string;
|
||||
asterPrivateKey?: string;
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
name: string;
|
||||
ai_model_id: string;
|
||||
exchange_id: string;
|
||||
initial_balance: number;
|
||||
btc_eth_leverage?: number;
|
||||
altcoin_leverage?: number;
|
||||
trading_symbols?: string;
|
||||
custom_prompt?: string;
|
||||
override_base_prompt?: boolean;
|
||||
system_prompt_template?: string;
|
||||
is_cross_margin?: boolean;
|
||||
use_coin_pool?: boolean;
|
||||
use_oi_top?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateModelConfigRequest {
|
||||
models: {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
custom_api_url?: string;
|
||||
custom_model_name?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateExchangeConfigRequest {
|
||||
exchanges: {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
secret_key: string;
|
||||
testnet?: boolean;
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquid_wallet_addr?: string;
|
||||
// Aster 特定字段
|
||||
aster_user?: string;
|
||||
aster_signer?: string;
|
||||
aster_private_key?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Competition related types
|
||||
export interface CompetitionTraderData {
|
||||
trader_id: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange: string;
|
||||
total_equity: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
position_count: number;
|
||||
margin_used_pct: number;
|
||||
call_count: number;
|
||||
is_running: boolean;
|
||||
}
|
||||
|
||||
@@ -108,3 +182,21 @@ export interface CompetitionData {
|
||||
traders: CompetitionTraderData[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Trader Configuration Data for View Modal
|
||||
export interface TraderConfigData {
|
||||
trader_id?: string;
|
||||
trader_name: string;
|
||||
ai_model: string;
|
||||
exchange_id: string;
|
||||
btc_eth_leverage: number;
|
||||
altcoin_leverage: number;
|
||||
trading_symbols: string;
|
||||
custom_prompt: string;
|
||||
override_base_prompt: boolean;
|
||||
is_cross_margin: boolean;
|
||||
use_coin_pool: boolean;
|
||||
use_oi_top: boolean;
|
||||
initial_balance: number;
|
||||
is_running: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user