mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-03 19:11:02 +08:00
Merge branch 'dev' into quote-tweet-link
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user