- {getExchangeIcon(selectedExchange.id, { width: 32, height: 32 })}
+ {getExchangeIcon(selectedExchange.id, {
+ width: 32,
+ height: 32,
+ })}
-
{getShortName(selectedExchange.name)}
+
+ {getShortName(selectedExchange.name)}
+
- {selectedExchange.type.toUpperCase()} • {selectedExchange.id}
+ {selectedExchange.type.toUpperCase()} •{' '}
+ {selectedExchange.id}
@@ -1285,62 +1794,263 @@ function ExchangeConfigModal({
{selectedExchange && (
<>
{/* Binance 和其他 CEX 交易所的字段 */}
- {(selectedExchange.id === 'binance' || selectedExchange.type === 'cex') && selectedExchange.id !== 'hyperliquid' && selectedExchange.id !== 'aster' && (
- <>
-
-
- {t('apiKey', language)}
-
- setApiKey(e.target.value)}
- placeholder={t('enterAPIKey', language)}
- className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
- required
- />
-
+ {(selectedExchange.id === 'binance' ||
+ selectedExchange.type === 'cex') &&
+ selectedExchange.id !== 'hyperliquid' &&
+ selectedExchange.id !== 'aster' && (
+ <>
+ {/* 币安用户配置提示 (D1 方案) */}
+ {selectedExchange.id === 'binance' && (
+
setShowBinanceGuide(!showBinanceGuide)}
+ >
+
+
+ ℹ️
+
+ 币安用户必读:
+ 使用「现货与合约交易」API,不要用「统一账户 API」
+
+
+
+ {showBinanceGuide ? '▲' : '▼'}
+
+
-
-
- {t('secretKey', language)}
-
- setSecretKey(e.target.value)}
- placeholder={t('enterSecretKey', language)}
- className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
- required
- />
-
+ {/* 展开的详细说明 */}
+ {showBinanceGuide && (
+
e.stopPropagation()}
+ >
+
+ 原因: 统一账户 API
+ 权限结构不同,会导致订单提交失败
+
+
+
+ 正确配置步骤:
+
+
+
+ 登录币安 → 个人中心 → API 管理
+
+
+ 创建 API → 选择「
+ 系统生成的 API 密钥 」
+
+
+ 勾选「现货与合约交易 」(
+
+ 不选统一账户
+
+ )
+
+
+ IP 限制选「无限制 」或添加服务器
+ IP
+
+
+
+
+ 💡 多资产模式用户注意:
+ 如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。
+
+
+
+ 📖 查看币安官方教程 ↗
+
+
+ )}
+
+ )}
- {selectedExchange.id === 'okx' && (
-
- {t('passphrase', language)}
+
+ {t('apiKey', language)}
setPassphrase(e.target.value)}
- placeholder={t('enterPassphrase', language)}
+ value={apiKey}
+ onChange={(e) => setApiKey(e.target.value)}
+ placeholder={t('enterAPIKey', language)}
className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
required
/>
- )}
- >
- )}
+
+
+
+ {t('secretKey', language)}
+
+ setSecretKey(e.target.value)}
+ placeholder={t('enterSecretKey', language)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
+ required
+ />
+
+
+
+
+ {t('secretKey', language)}
+
+ setSecretKey(e.target.value)}
+ placeholder={t('enterSecretKey', language)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
+ required
+ />
+
+
+ {selectedExchange.id === 'okx' && (
+
+
+ {t('passphrase', language)}
+
+ setPassphrase(e.target.value)}
+ placeholder={t('enterPassphrase', language)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
+ required
+ />
+
+ )}
+
+ {/* Binance 白名单IP提示 */}
+ {selectedExchange.id === 'binance' && (
+
+
+ {t('whitelistIP', language)}
+
+
+ {t('whitelistIPDesc', language)}
+
+
+ {loadingIP ? (
+
+ {t('loadingServerIP', language)}
+
+ ) : serverIP && serverIP.public_ip ? (
+
+
+ {serverIP.public_ip}
+
+ handleCopyIP(serverIP.public_ip)}
+ className="px-3 py-1 rounded text-xs font-semibold transition-all hover:scale-105"
+ style={{
+ background: 'rgba(240, 185, 11, 0.2)',
+ color: '#F0B90B',
+ }}
+ >
+ {copiedIP
+ ? t('ipCopied', language)
+ : t('copyIP', language)}
+
+
+ ) : null}
+
+ )}
+ >
+ )}
{/* Hyperliquid 交易所的字段 */}
{selectedExchange.id === 'hyperliquid' && (
<>
-
-
-
- {t('walletAddress', language)}
-
-
setHyperliquidWalletAddr(e.target.value)}
- placeholder={t('enterWalletAddress', language)}
- className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
- required
- />
-
- {t('hyperliquidWalletAddressDesc', language)}
-
-
>
)}
@@ -1381,8 +2077,17 @@ function ExchangeConfigModal({
{selectedExchange.id === 'aster' && (
<>
-
+
{t('user', language)}
+
+
+
setAsterUser(e.target.value)}
placeholder={t('enterUser', language)}
className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
required
/>
-
+
{t('signer', language)}
+
+
+
setAsterSigner(e.target.value)}
placeholder={t('enterSigner', language)}
className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
required
/>
-
+
{t('privateKey', language)}
+
+
+
setAsterPrivateKey(e.target.value)}
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
required
/>
@@ -1436,18 +2171,35 @@ function ExchangeConfigModal({
className="form-checkbox rounded"
style={{ accentColor: '#F0B90B' }}
/>
-
{t('useTestnet', language)}
+
+ {t('useTestnet', language)}
+
{t('testnetDescription', language)}
-
-
{t('securityWarning', language)}
+
+
+
+ {' '}
+ {t('securityWarning', language)}
+
+ {selectedExchange.id === 'aster' && (
+
{t('asterUsdtWarning', language)}
+ )}
{t('exchangeConfigWarning1', language)}
{t('exchangeConfigWarning2', language)}
{t('exchangeConfigWarning3', language)}
@@ -1468,12 +2220,24 @@ function ExchangeConfigModal({
+
+ {/* Binance Setup Guide Modal */}
+ {showGuide && (
+
setShowGuide(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+
+ {t('binanceSetupGuide', language)}
+
+ setShowGuide(false)}
+ className="px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
+ style={{ background: '#2B3139', color: '#848E9C' }}
+ >
+ {t('closeGuide', language)}
+
+
+
+
+
+
+
+ )}
- );
+ )
}
diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx
index 7a933920..dc81c9cf 100644
--- a/web/src/components/ComparisonChart.tsx
+++ b/web/src/components/ComparisonChart.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo } from 'react'
import {
LineChart,
Line,
@@ -9,129 +9,140 @@ import {
ResponsiveContainer,
ReferenceLine,
Legend,
-} from 'recharts';
-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';
+} from 'recharts'
+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[];
+ traders: CompetitionTraderData[]
}
export function ComparisonChart({ traders }: ComparisonChartProps) {
- const { language } = useLanguage();
+ const { language } = useLanguage()
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
// 生成唯一的key,当traders变化时会触发重新请求
- const tradersKey = traders.map(t => t.trader_id).sort().join(',');
+ const tradersKey = traders
+ .map((t) => t.trader_id)
+ .sort()
+ .join(',')
const { data: allTraderHistories, isLoading } = useSWR(
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
async () => {
// 使用批量API一次性获取所有trader的历史数据
- const traderIds = traders.map(trader => trader.trader_id);
- const batchData = await api.getEquityHistoryBatch(traderIds);
-
+ const traderIds = traders.map((trader) => trader.trader_id)
+ const batchData = await api.getEquityHistoryBatch(traderIds)
+
// 转换为原格式,保持与原有代码兼容
- return traders.map(trader => {
- return batchData.histories[trader.trader_id] || [];
- });
+ return traders.map((trader) => {
+ return batchData.histories[trader.trader_id] || []
+ })
},
{
refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
revalidateOnFocus: false,
dedupingInterval: 20000,
}
- );
+ )
// 将数据转换为与原格式兼容的结构
const traderHistories = useMemo(() => {
if (!allTraderHistories) {
- return traders.map(() => ({ data: undefined }));
+ return traders.map(() => ({ data: undefined }))
}
- return allTraderHistories.map(data => ({ data }));
- }, [allTraderHistories, traders.length]);
+ return allTraderHistories.map((data) => ({ data }))
+ }, [allTraderHistories, traders.length])
// 使用useMemo自动处理数据合并,直接使用data对象作为依赖
const combinedData = useMemo(() => {
// 等待所有数据加载完成
- const allLoaded = traderHistories.every((h) => h.data);
- if (!allLoaded) return [];
+ const allLoaded = traderHistories.every((h) => h.data)
+ if (!allLoaded) return []
- console.log(`[${new Date().toISOString()}] Recalculating chart data...`);
+ console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
// 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
// 收集所有时间戳
- const timestampMap = new Map
;
- }>();
+ const timestampMap = new Map<
+ string,
+ {
+ timestamp: string
+ time: string
+ traders: Map
+ }
+ >()
traderHistories.forEach((history, index) => {
- const trader = traders[index];
- if (!history.data) return;
+ const trader = traders[index]
+ if (!history.data) return
- console.log(`Trader ${trader.trader_id}: ${history.data.length} data points`);
+ console.log(
+ `Trader ${trader.trader_id}: ${history.data.length} data points`
+ )
history.data.forEach((point: any) => {
- const ts = point.timestamp;
+ const ts = point.timestamp
if (!timestampMap.has(ts)) {
const time = new Date(ts).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
- });
+ })
timestampMap.set(ts, {
timestamp: ts,
time,
- traders: new Map()
- });
+ traders: new Map(),
+ })
}
// 计算盈亏百分比:从total_pnl和balance计算
// 假设初始余额 = balance - total_pnl
- const initialBalance = point.balance - point.total_pnl;
- const pnlPct = initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0;
+ const initialBalance = point.balance - point.total_pnl
+ const pnlPct =
+ initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0
timestampMap.get(ts)!.traders.set(trader.trader_id, {
pnl_pct: pnlPct,
- equity: point.total_equity
- });
- });
- });
+ equity: point.total_equity,
+ })
+ })
+ })
// 按时间戳排序,转换为数组
const combined = Array.from(timestampMap.entries())
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
.map(([ts, data], index) => {
const entry: any = {
- index: index + 1, // 使用序号代替cycle
+ index: index + 1, // 使用序号代替cycle
time: data.time,
- timestamp: ts
- };
+ timestamp: ts,
+ }
traders.forEach((trader) => {
- const traderData = data.traders.get(trader.trader_id);
+ const traderData = data.traders.get(trader.trader_id)
if (traderData) {
- entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct;
- entry[`${trader.trader_id}_equity`] = traderData.equity;
+ entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct
+ entry[`${trader.trader_id}_equity`] = traderData.equity
}
- });
+ })
- return entry;
- });
+ return entry
+ })
if (combined.length > 0) {
- const lastPoint = combined[combined.length - 1];
- console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
+ const lastPoint = combined[combined.length - 1]
+ console.log(
+ `Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`
+ )
}
- return combined;
- }, [allTraderHistories, traders]);
+ return combined
+ }, [allTraderHistories, traders])
if (isLoading) {
return (
@@ -139,67 +150,69 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
Loading comparison data...
- );
+ )
}
if (combinedData.length === 0) {
return (
-
{t('noHistoricalData', language)}
+
+ {t('noHistoricalData', language)}
+
{t('dataWillAppear', language)}
- );
+ )
}
// 限制显示数据点
- const MAX_DISPLAY_POINTS = 2000;
+ const MAX_DISPLAY_POINTS = 2000
const displayData =
combinedData.length > MAX_DISPLAY_POINTS
? combinedData.slice(-MAX_DISPLAY_POINTS)
- : combinedData;
+ : combinedData
// 计算Y轴范围
const calculateYDomain = () => {
- const allValues: number[] = [];
+ const allValues: number[] = []
displayData.forEach((point) => {
traders.forEach((trader) => {
- const value = point[`${trader.trader_id}_pnl_pct`];
+ const value = point[`${trader.trader_id}_pnl_pct`]
if (value !== undefined) {
- allValues.push(value);
+ allValues.push(value)
}
- });
- });
+ })
+ })
- if (allValues.length === 0) return [-5, 5];
+ if (allValues.length === 0) return [-5, 5]
- const minVal = Math.min(...allValues);
- const maxVal = Math.max(...allValues);
- const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
- const padding = Math.max(range * 0.2, 1); // 至少留1%余量
+ const minVal = Math.min(...allValues)
+ const maxVal = Math.max(...allValues)
+ const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
+ const padding = Math.max(range * 0.2, 1) // 至少留1%余量
- return [
- Math.floor(minVal - padding),
- Math.ceil(maxVal + padding)
- ];
- };
+ return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
+ }
// 使用统一的颜色分配逻辑(与Leaderboard保持一致)
- const traderColor = (traderId: string) => getTraderColor(traders, traderId);
+ const traderColor = (traderId: string) => getTraderColor(traders, traderId)
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
- const data = payload[0].payload;
+ const data = payload[0].payload
return (
-
+
{data.time} - #{data.index}
{traders.map((trader) => {
- const pnlPct = data[`${trader.trader_id}_pnl_pct`];
- const equity = data[`${trader.trader_id}_equity`];
- if (pnlPct === undefined) return null;
+ const pnlPct = data[`${trader.trader_id}_pnl_pct`]
+ const equity = data[`${trader.trader_id}_equity`]
+ if (pnlPct === undefined) return null
return (
@@ -209,33 +222,51 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
>
{trader.trader_name}
-
= 0 ? '#0ECB81' : '#F6465D' }}>
- {pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
-
+ = 0 ? '#0ECB81' : '#F6465D' }}
+ >
+ {pnlPct >= 0 ? '+' : ''}
+ {pnlPct.toFixed(2)}%
+
({equity?.toFixed(2)} USDT)
- );
+ )
})}
- );
+ )
}
- return null;
- };
+ return null
+ }
// 计算当前差距
- const currentGap = displayData.length > 0 ? (() => {
- const lastPoint = displayData[displayData.length - 1];
- const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0);
- return Math.abs(values[0] - values[1]);
- })() : 0;
+ const currentGap =
+ displayData.length > 0
+ ? (() => {
+ const lastPoint = displayData[displayData.length - 1]
+ const values = traders.map(
+ (t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
+ )
+ return Math.abs(values[0] - values[1])
+ })()
+ : 0
return (
-
+
{/* NOFX Watermark */}
-
NOFX
-
-
- {traders.map((trader) => (
-
-
-
-
- ))}
-
+
+
+ {traders.map((trader) => (
+
+
+
+
+ ))}
+
-
+
-
-
- `${value.toFixed(1)}%`}
- width={60}
- />
-
- } />
-
-
-
- {traders.map((trader) => (
-
- ))}
- {
- const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
- const trader = traders.find((t) => t.trader_id === traderId);
- return (
-
- {trader?.trader_name} ({trader?.ai_model.toUpperCase()})
-
- );
- }}
- />
-
-
+
`${value.toFixed(1)}%`}
+ width={60}
+ />
+
+ } />
+
+
+
+ {traders.map((trader) => (
+
+ ))}
+
+ {
+ const traderId = traders.find(
+ (t) => value === t.trader_name
+ )?.trader_id
+ const trader = traders.find((t) => t.trader_id === traderId)
+ return (
+
+ {trader?.trader_name} ({trader?.ai_model.toUpperCase()})
+
+ )
+ }}
+ />
+
+
{/* Stats */}
-
-
-
{t('comparisonMode', language)}
-
PnL %
+
+
+
+ {t('comparisonMode', language)}
+
+
+ PnL %
+
-
-
{t('dataPoints', language)}
-
{t('count', language, {count: combinedData.length})}
+
+
+ {t('dataPoints', language)}
+
+
+ {t('count', language, { count: combinedData.length })}
+
-
-
{t('currentGap', language)}
-
1 ? '#F0B90B' : '#EAECEF' }}>
+
+
+ {t('currentGap', language)}
+
+
1 ? '#F0B90B' : '#EAECEF' }}
+ >
{currentGap.toFixed(2)}%
-
-
{t('displayRange', language)}
-
+
+
+ {t('displayRange', language)}
+
+
{combinedData.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)}
@@ -362,5 +472,5 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
- );
+ )
}
diff --git a/web/src/components/CompetitionPage.tsx b/web/src/components/CompetitionPage.tsx
index d719472e..2c1effd2 100644
--- a/web/src/components/CompetitionPage.tsx
+++ b/web/src/components/CompetitionPage.tsx
@@ -1,18 +1,18 @@
-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 { 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'
export function CompetitionPage() {
- const { language } = useLanguage();
- const [selectedTrader, setSelectedTrader] = useState
(null);
- const [isModalOpen, setIsModalOpen] = useState(false);
+ const { language } = useLanguage()
+ const [selectedTrader, setSelectedTrader] = useState(null)
+ const [isModalOpen, setIsModalOpen] = useState(false)
const { data: competition } = useSWR(
'competition',
@@ -22,24 +22,24 @@ export function CompetitionPage() {
revalidateOnFocus: false,
dedupingInterval: 10000,
}
- );
+ )
const handleTraderClick = async (traderId: string) => {
try {
- const traderConfig = await api.getTraderConfig(traderId);
- setSelectedTrader(traderConfig);
- setIsModalOpen(true);
+ const traderConfig = await api.getTraderConfig(traderId)
+ setSelectedTrader(traderConfig)
+ setIsModalOpen(true)
} catch (error) {
- console.error('Failed to fetch trader config:', error);
+ console.error('Failed to fetch trader config:', error)
// 对于未登录用户,不显示详细配置,这是正常行为
// 竞赛页面主要用于查看排行榜和基本信息
}
- };
+ }
const closeModal = () => {
- setIsModalOpen(false);
- setSelectedTrader(null);
- };
+ setIsModalOpen(false)
+ setSelectedTrader(null)
+ }
if (!competition) {
return (
@@ -61,7 +61,7 @@ export function CompetitionPage() {
- );
+ )
}
// 如果有数据返回但没有交易员,显示空状态
@@ -71,16 +71,31 @@ export function CompetitionPage() {
{/* Competition Header - 精简版 */}
-
-
+
+
-
+
{t('aiCompetition', language)}
-
+
0 {t('traders', language)}
@@ -93,7 +108,10 @@ export function CompetitionPage() {
{/* Empty State */}
-
+
{t('noTraders', language)}
@@ -102,32 +120,47 @@ export function CompetitionPage() {
- );
+ )
}
// 按收益率排序
const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct
- );
+ )
// 找出领先者
- const leader = sortedTraders[0];
+ const leader = sortedTraders[0]
return (
{/* Competition Header - 精简版 */}
-
-
+
+
-
+
{t('aiCompetition', language)}
-
+
{competition.count} {t('traders', language)}
@@ -137,10 +170,23 @@ export function CompetitionPage() {
-
{t('leader', language)}
-
{leader?.trader_name}
-
= 0 ? '#0ECB81' : '#F6465D' }}>
- {(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
+
+ {t('leader', language)}
+
+
+ {leader?.trader_name}
+
+
= 0 ? '#0ECB81' : '#F6465D',
+ }}
+ >
+ {(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
+ {leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
@@ -148,9 +194,15 @@ export function CompetitionPage() {
{/* Left/Right Split: Performance Chart + Leaderboard */}
{/* Left: Performance Comparison Chart */}
-
+
-
+
{t('performanceComparison', language)}
@@ -161,19 +213,35 @@ export function CompetitionPage() {
{/* Right: Leaderboard */}
-
+
-
+
{t('leaderboard', language)}
-
{sortedTraders.map((trader, index) => {
- const isLeader = index === 0;
- const traderColor = getTraderColor(sortedTraders, trader.trader_id);
+ const isLeader = index === 0
+ const traderColor = getTraderColor(
+ sortedTraders,
+ trader.trader_id
+ )
return (
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',
+ background: isLeader
+ ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)'
+ : '#0B0E11',
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
- boxShadow: isLeader ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' : '0 1px 4px rgba(0, 0, 0, 0.3)'
+ boxShadow: isLeader
+ ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)'
+ : '0 1px 4px rgba(0, 0, 0, 0.3)',
}}
>
{/* Rank & Name */}
-
+
-
{trader.trader_name}
-
- {trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
+
+ {trader.trader_name}
+
+
+ {trader.ai_model.toUpperCase()} +{' '}
+ {trader.exchange.toUpperCase()}
@@ -204,31 +295,52 @@ export function CompetitionPage() {
{/* Total Equity */}
-
{t('equity', language)}
-
+
+ {t('equity', language)}
+
+
{trader.total_equity?.toFixed(2) || '0.00'}
{/* P&L */}
-
{t('pnl', language)}
+
+ {t('pnl', language)}
+
= 0 ? '#0ECB81' : '#F6465D' }}
+ style={{
+ color:
+ (trader.total_pnl ?? 0) >= 0
+ ? '#0ECB81'
+ : '#F6465D',
+ }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
-
- {(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
+
+ {(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
+ {trader.total_pnl?.toFixed(2) || '0.00'}
{/* Positions */}
-
{t('pos', language)}
-
+
+ {t('pos', language)}
+
+
{trader.position_count}
@@ -240,9 +352,16 @@ export function CompetitionPage() {
{trader.is_running ? '●' : '○'}
@@ -251,7 +370,7 @@ export function CompetitionPage() {
- );
+ )
})}
@@ -259,56 +378,81 @@ export function CompetitionPage() {
{/* Head-to-Head Stats */}
{competition.traders.length === 2 && (
-
-
+
+
{t('headToHead', language)}
{sortedTraders.map((trader, index) => {
- const isWinning = index === 0;
- const opponent = sortedTraders[1 - index];
- const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
+ const isWinning = index === 0
+ const opponent = sortedTraders[1 - index]
+ const gap = trader.total_pnl_pct - opponent.total_pnl_pct
return (
{trader.trader_name}
-
= 0 ? '#0ECB81' : '#F6465D' }}>
- {(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
+
= 0 ? '#0ECB81' : '#F6465D',
+ }}
+ >
+ {(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
+ {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
{isWinning && gap > 0 && (
-
+
{t('leadingBy', language, { gap: gap.toFixed(2) })}
)}
{!isWinning && gap < 0 && (
-
- {t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })}
+
+ {t('behindBy', language, {
+ gap: Math.abs(gap).toFixed(2),
+ })}
)}
- );
+ )
})}
@@ -321,5 +465,5 @@ export function CompetitionPage() {
traderData={selectedTrader}
/>
- );
+ )
}
diff --git a/web/src/components/CryptoFeatureCard.tsx b/web/src/components/CryptoFeatureCard.tsx
index 0affa99d..d206d8ae 100644
--- a/web/src/components/CryptoFeatureCard.tsx
+++ b/web/src/components/CryptoFeatureCard.tsx
@@ -1,115 +1,136 @@
-import * as React from "react";
-import { motion } from "framer-motion";
-import { Check } from "lucide-react";
-import { cn } from "../lib/utils";
+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;
+ icon: React.ReactNode
+ title: string
+ description: string
+ features: string[]
+ className?: string
+ delay?: number
}
-export const CryptoFeatureCard = React.forwardRef
(
- ({ icon, title, description, features, className, delay = 0 }, ref) => {
- const [isHovered, setIsHovered] = React.useState(false);
+export const CryptoFeatureCard = React.forwardRef<
+ HTMLDivElement,
+ CryptoFeatureCardProps
+>(({ icon, title, description, features, className, delay = 0 }, ref) => {
+ const [isHovered, setIsHovered] = React.useState(false)
- return (
- setIsHovered(true)}
- onHoverEnd={() => setIsHovered(false)}
- className="relative h-full"
+ return (
+ setIsHovered(true)}
+ onHoverEnd={() => setIsHovered(false)}
+ className="relative h-full"
+ >
+
-
- {/* Animated glow border effect */}
+
+
+
+ {/* Background pattern */}
+
+
+
+ {/* Icon container */}
-
+ {icon}
- {/* Background pattern */}
-
+ {/* Title */}
+
+ {title}
+
-
- {/* Icon container */}
-
- {icon}
-
+ {/* Description */}
+
+ {description}
+
- {/* Title */}
-
{title}
-
- {/* Description */}
-
{description}
-
- {/* Features list */}
-
- {features.map((feature, index) => (
-
-
-
-
-
+ {/* Features list */}
+
+ {features.map((feature, index) => (
+
+
+
+
-
{feature}
-
- ))}
-
-
+
+
+ {feature}
+
+
+ ))}
-
-
- );
- }
-);
+
+
+ )
+})
-CryptoFeatureCard.displayName = "CryptoFeatureCard";
+CryptoFeatureCard.displayName = 'CryptoFeatureCard'
diff --git a/web/src/components/EquityChart.tsx b/web/src/components/EquityChart.tsx
index e7441779..46ce49b0 100644
--- a/web/src/components/EquityChart.tsx
+++ b/web/src/components/EquityChart.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState } from 'react'
import {
LineChart,
Line,
@@ -8,28 +8,35 @@ import {
Tooltip,
ResponsiveContainer,
ReferenceLine,
-} from 'recharts';
-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'
+} from 'recharts'
+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;
- total_equity: number;
- pnl: number;
- pnl_pct: number;
- cycle_number: number;
+ timestamp: string
+ total_equity: number
+ pnl: number
+ pnl_pct: number
+ cycle_number: number
}
interface EquityChartProps {
- traderId?: string;
+ traderId?: string
}
export function EquityChart({ traderId }: EquityChartProps) {
- const { language } = useLanguage();
- const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar');
+ const { language } = useLanguage()
+ const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR
(
traderId ? `equity-history-${traderId}` : 'equity-history',
@@ -39,7 +46,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 20000,
}
- );
+ )
const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account',
@@ -49,24 +56,24 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 10000,
}
- );
+ )
if (error) {
return (
-
+
-
+
-
+
{t('loadingError', language)}
-
@@ -76,22 +83,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
}
// 过滤掉无效数据:total_equity为0或小于1的数据点(API失败导致)
- const validHistory = history?.filter(point => point.total_equity > 1) || [];
+ const validHistory = history?.filter((point) => point.total_equity > 1) || []
if (!validHistory || validHistory.length === 0) {
return (
-
-
+
+
{t('accountEquityCurve', language)}
-
-
-
+
+
+
-
+
{t('noHistoricalData', language)}
-
{t('dataWillAppear', language)}
+
{t('dataWillAppear', language)}
)
@@ -99,20 +106,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
// 限制显示最近的数据点(性能优化)
// 如果数据超过2000个点,只显示最近2000个
- const MAX_DISPLAY_POINTS = 2000;
- const displayHistory = validHistory.length > MAX_DISPLAY_POINTS
- ? validHistory.slice(-MAX_DISPLAY_POINTS)
- : validHistory;
+ const MAX_DISPLAY_POINTS = 2000
+ const displayHistory =
+ validHistory.length > MAX_DISPLAY_POINTS
+ ? validHistory.slice(-MAX_DISPLAY_POINTS)
+ : validHistory
- // 计算初始余额(使用第一个有效数据点,如果无数据则从account获取,最后才用默认值)
- const initialBalance = validHistory[0]?.total_equity
- || account?.total_equity
- || 100; // 默认值改为100,与常见配置一致
+ // 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推)
+ const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
+ || (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) // 备选:淨值 - 盈亏
+ || 1000; // 默认值(与创建交易员时的默认配置一致)
// 转换数据格式
const chartData = displayHistory.map((point) => {
- const pnl = point.total_equity - initialBalance;
- const pnlPct = ((pnl / initialBalance) * 100).toFixed(2);
+ const pnl = point.total_equity - initialBalance
+ const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
return {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -123,43 +131,45 @@ export function EquityChart({ traderId }: EquityChartProps) {
raw_equity: point.total_equity,
raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct),
- };
- });
+ }
+ })
- const currentValue = chartData[chartData.length - 1];
- const isProfit = currentValue.raw_pnl >= 0;
+ const currentValue = chartData[chartData.length - 1]
+ const isProfit = currentValue.raw_pnl >= 0
// 计算Y轴范围
const calculateYDomain = () => {
if (displayMode === 'percent') {
// 百分比模式:找到最大最小值,留20%余量
- const values = chartData.map(d => d.value);
- const minVal = Math.min(...values);
- const maxVal = Math.max(...values);
- const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
- const padding = Math.max(range * 0.2, 1); // 至少留1%余量
- return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)];
+ const values = chartData.map((d) => d.value)
+ const minVal = Math.min(...values)
+ const maxVal = Math.max(...values)
+ const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
+ const padding = Math.max(range * 0.2, 1) // 至少留1%余量
+ return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
} else {
// 美元模式:以初始余额为基准,上下留10%余量
- const values = chartData.map(d => d.value);
- const minVal = Math.min(...values, initialBalance);
- const maxVal = Math.max(...values, initialBalance);
- const range = maxVal - minVal;
- const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量
- return [
- Math.floor(minVal - padding),
- Math.ceil(maxVal + padding)
- ];
+ const values = chartData.map((d) => d.value)
+ const minVal = Math.min(...values, initialBalance)
+ const maxVal = Math.max(...values, initialBalance)
+ const range = maxVal - minVal
+ const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
+ return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
}
- };
+ }
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
- const data = payload[0].payload;
+ const data = payload[0].payload
return (
-
-
Cycle #{data.cycle}
+
+
+ Cycle #{data.cycle}
+
{data.raw_equity.toFixed(2)} USDT
@@ -172,38 +182,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%)
- );
+ )
}
- return null;
- };
+ return null
+ }
return (
-
+
{/* Header */}
-
-
+
+
{t('accountEquityCurve', language)}
-
+
{account?.total_equity.toFixed(2) || '0.00'}
USDT
-
+
- {isProfit ? : }
+ {isProfit ? (
+
+ ) : (
+
+ )}
{isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
({isProfit ? '+' : ''}
@@ -233,12 +247,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Display Mode Toggle */}
setDisplayMode('dollar')}
- 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'
+ 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'
? {
@@ -249,11 +263,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
- USDT
+ USDT
setDisplayMode('percent')}
- 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'
+ 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'
? {
@@ -264,15 +278,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
-
+
{/* Chart */}
-
+
{/* NOFX Watermark */}
-
NOFX
-
+
-
-
-
+
+
+
-
+
} />
50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{
@@ -352,72 +373,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Footer Stats */}
{t('initialBalance', language)}
{initialBalance.toFixed(2)} USDT
{t('currentEquity', language)}
{currentValue.raw_equity.toFixed(2)} USDT
{t('historicalCycles', language)}
{validHistory.length} {t('cycles', language)}
{t('displayRange', language)}
{validHistory.length > MAX_DISPLAY_POINTS
diff --git a/web/src/components/ExchangeIcons.tsx b/web/src/components/ExchangeIcons.tsx
index c3056dd0..0ffa695c 100644
--- a/web/src/components/ExchangeIcons.tsx
+++ b/web/src/components/ExchangeIcons.tsx
@@ -1,107 +1,165 @@
-import React from 'react';
+import React from 'react'
interface IconProps {
- width?: number;
- height?: number;
- className?: string;
+ width?: number
+ height?: number
+ className?: string
}
// Binance SVG 图标组件
-const BinanceIcon: React.FC
= ({ width = 24, height = 24, className }) => (
- = ({
+ width = 24,
+ height = 24,
+ className,
+}) => (
+
-
-);
+)
// Hyperliquid SVG 图标组件
-const HyperliquidIcon: React.FC = ({ width = 24, height = 24, className }) => (
- = ({
+ width = 24,
+ height = 24,
+ className,
+}) => (
+
-
-);
+)
// Aster SVG 图标组件
-const AsterIcon: React.FC = ({ width = 24, height = 24, className }) => (
- = ({
+ width = 24,
+ height = 24,
+ className,
+}) => (
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-);
+)
// 获取交易所图标的函数
-export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
+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 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
- };
-
+ className: props.className,
+ }
+
switch (type) {
case 'binance':
case 'cex':
- return ;
+ return
case 'hyperliquid':
case 'dex':
- return ;
+ return
case 'aster':
- return ;
+ return
default:
return (
-
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
- color: '#EAECEF'
+ color: '#EAECEF',
}}
>
{type[0]?.toUpperCase() || '?'}
- );
+ )
}
-};
\ No newline at end of file
+}
diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx
index 06352dee..e39731c1 100644
--- a/web/src/components/Header.tsx
+++ b/web/src/components/Header.tsx
@@ -1,12 +1,12 @@
-import { useLanguage } from '../contexts/LanguageContext';
-import { t } from '../i18n/translations';
+import { useLanguage } from '../contexts/LanguageContext'
+import { t } from '../i18n/translations'
interface HeaderProps {
- simple?: boolean; // For login/register pages
+ simple?: boolean // For login/register pages
}
export function Header({ simple = false }: HeaderProps) {
- const { language, setLanguage } = useLanguage();
+ const { language, setLanguage } = useLanguage()
return (
@@ -28,15 +28,19 @@ export function Header({ simple = false }: HeaderProps) {
)}
-
+
{/* Right - Language Toggle (always show) */}
-
+
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' }
+ style={
+ language === 'zh'
+ ? { background: '#F0B90B', color: '#000' }
+ : { background: 'transparent', color: '#848E9C' }
}
>
中文
@@ -44,9 +48,10 @@ export function Header({ simple = false }: HeaderProps) {
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' }
+ style={
+ language === 'en'
+ ? { background: '#F0B90B', color: '#000' }
+ : { background: 'transparent', color: '#848E9C' }
}
>
EN
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
- );
+ )
}
diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx
index a1ed3512..d73d078b 100644
--- a/web/src/components/LoginPage.tsx
+++ b/web/src/components/LoginPage.tsx
@@ -1,208 +1,272 @@
-import React, { useState } from 'react';
-import { useAuth } from '../contexts/AuthContext';
-import { useLanguage } from '../contexts/LanguageContext';
-import { t } from '../i18n/translations';
-import HeaderBar from './landing/HeaderBar';
+import React, { useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { useLanguage } from '../contexts/LanguageContext'
+import { t } from '../i18n/translations'
+import HeaderBar from './landing/HeaderBar'
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 { 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);
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ const result = await login(email, password)
- const result = await login(email, password);
-
if (result.success) {
if (result.requiresOTP && result.userID) {
- setUserID(result.userID);
- setStep('otp');
+ setUserID(result.userID)
+ setStep('otp')
}
} else {
- setError(result.message || t('loginFailed', language));
+ setError(result.message || t('loginFailed', language))
}
-
- setLoading(false);
- };
+
+ setLoading(false)
+ }
const handleOTPVerify = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
- setLoading(true);
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ const result = await verifyOTP(userID, otpCode)
- const result = await verifyOTP(userID, otpCode);
-
if (!result.success) {
- setError(result.message || t('verificationFailed', language));
+ setError(result.message || t('verificationFailed', language))
}
// 成功的话AuthContext会自动处理登录状态
-
- setLoading(false);
- };
+
+ setLoading(false)
+ }
return (
-
{}}
- isLoggedIn={false}
+ {}}
+ isLoggedIn={false}
isHomePage={false}
currentPage="login"
language={language}
onLanguageChange={() => {}}
onPageChange={(page) => {
- console.log('LoginPage onPageChange called with:', page);
+ console.log('LoginPage onPageChange called with:', page)
if (page === 'competition') {
- window.location.href = '/competition';
+ window.location.href = '/competition'
}
}}
/>
-
+
-
{/* Logo */}
-
+
-
+
登录 NOFX
-
+
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
- {/* Login Form */}
-
- {step === 'login' ? (
-
- );
+ )
}
diff --git a/web/src/components/ModelIcons.tsx b/web/src/components/ModelIcons.tsx
index c9cb1ff8..78dbd418 100644
--- a/web/src/components/ModelIcons.tsx
+++ b/web/src/components/ModelIcons.tsx
@@ -1,26 +1,25 @@
-
interface IconProps {
- width?: number;
- height?: number;
- className?: string;
+ 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;
-
+ const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
+
+ let iconPath: string | null = null
+
switch (type) {
case 'deepseek':
- iconPath = '/icons/deepseek.svg';
- break;
+ iconPath = '/icons/deepseek.svg'
+ break
case 'qwen':
- iconPath = '/icons/qwen.svg';
- break;
+ iconPath = '/icons/qwen.svg'
+ break
default:
- return null;
+ return null
}
return (
@@ -32,5 +31,5 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
className={props.className}
style={{ borderRadius: '50%' }}
/>
- );
-};
\ No newline at end of file
+ )
+}
diff --git a/web/src/components/RegisterPage.tsx b/web/src/components/RegisterPage.tsx
index 4fdbcace..3773714b 100644
--- a/web/src/components/RegisterPage.tsx
+++ b/web/src/components/RegisterPage.tsx
@@ -1,366 +1,502 @@
-import React, { useState, useEffect } from 'react';
-import { useAuth } from '../contexts/AuthContext';
-import { useLanguage } from '../contexts/LanguageContext';
-import { t } from '../i18n/translations';
-import { getSystemConfig } from '../lib/config';
-import HeaderBar from './landing/HeaderBar';
+import React, { useState, useEffect } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { useLanguage } from '../contexts/LanguageContext'
+import { t } from '../i18n/translations'
+import { getSystemConfig } from '../lib/config'
+import HeaderBar from './landing/HeaderBar'
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 [betaCode, setBetaCode] = useState('');
- const [betaMode, setBetaMode] = useState(false);
- 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 { 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 [betaCode, setBetaCode] = useState('')
+ const [betaMode, setBetaMode] = useState(false)
+ 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)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式
- getSystemConfig().then(config => {
- setBetaMode(config.beta_mode || false);
- }).catch(err => {
- console.error('Failed to fetch system config:', err);
- });
- }, []);
+ getSystemConfig()
+ .then((config) => {
+ setBetaMode(config.beta_mode || false)
+ })
+ .catch((err) => {
+ console.error('Failed to fetch system config:', err)
+ })
+ }, [])
const handleRegister = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
+ e.preventDefault()
+ setError('')
if (password !== confirmPassword) {
- setError(t('passwordMismatch', language));
- return;
+ setError(t('passwordMismatch', language))
+ return
}
if (password.length < 6) {
- setError(t('passwordTooShort', language));
- return;
+ setError(t('passwordTooShort', language))
+ return
}
if (betaMode && !betaCode.trim()) {
- setError('内测期间,注册需要提供内测码');
- return;
+ setError('内测期间,注册需要提供内测码')
+ return
}
- setLoading(true);
+ setLoading(true)
+
+ const result = await register(email, password, betaCode.trim() || undefined)
- const result = await register(email, password, betaCode.trim() || undefined);
-
if (result.success && result.userID) {
- setUserID(result.userID);
- setOtpSecret(result.otpSecret || '');
- setQrCodeURL(result.qrCodeURL || '');
- setStep('setup-otp');
+ setUserID(result.userID)
+ setOtpSecret(result.otpSecret || '')
+ setQrCodeURL(result.qrCodeURL || '')
+ setStep('setup-otp')
} else {
- setError(result.message || t('registrationFailed', language));
+ setError(result.message || t('registrationFailed', language))
}
-
- setLoading(false);
- };
+
+ setLoading(false)
+ }
const handleSetupComplete = () => {
- setStep('verify-otp');
- };
+ setStep('verify-otp')
+ }
const handleOTPVerify = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
- setLoading(true);
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+
+ const result = await completeRegistration(userID, otpCode)
- const result = await completeRegistration(userID, otpCode);
-
if (!result.success) {
- setError(result.message || t('registrationFailed', language));
+ setError(result.message || t('registrationFailed', language))
}
// 成功的话AuthContext会自动处理登录状态
-
- setLoading(false);
- };
+
+ setLoading(false)
+ }
const copyToClipboard = (text: string) => {
- navigator.clipboard.writeText(text);
- };
+ navigator.clipboard.writeText(text)
+ }
return (
-
{}}
onPageChange={(page) => {
- console.log('RegisterPage onPageChange called with:', page);
+ console.log('RegisterPage onPageChange called with:', page)
if (page === 'competition') {
- window.location.href = '/competition';
+ window.location.href = '/competition'
}
}}
/>
-
+
-
{/* Logo */}
-
-
+
+
+
+
+ {t('appTitle', language)}
+
+
+ {step === 'register' && t('registerTitle', language)}
+ {step === 'setup-otp' && t('setupTwoFactor', language)}
+ {step === 'verify-otp' && t('verifyOTP', language)}
+
-
- {t('appTitle', language)}
-
-
- {step === 'register' && t('registerTitle', language)}
- {step === 'setup-otp' && t('setupTwoFactor', language)}
- {step === 'verify-otp' && t('verifyOTP', language)}
-
-
- {/* Registration Form */}
-
- {step === 'register' && (
-
-
-
- {t('email', language)}
-
- setEmail(e.target.value)}
- className="w-full px-3 py-2 rounded"
- style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
- placeholder={t('emailPlaceholder', language)}
- required
- />
-
-
-
-
- {t('password', language)}
-
- setPassword(e.target.value)}
- className="w-full px-3 py-2 rounded"
- style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
- placeholder={t('passwordPlaceholder', language)}
- required
- />
-
-
-
-
- {t('confirmPassword', language)}
-
- setConfirmPassword(e.target.value)}
- className="w-full px-3 py-2 rounded"
- style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
- placeholder={t('confirmPasswordPlaceholder', language)}
- required
- />
-
-
- {betaMode && (
+ {/* Registration Form */}
+
+ {step === 'register' && (
+
- )}
- {error && (
-
- {error}
+
+
+ {t('password', language)}
+
+ setPassword(e.target.value)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: 'var(--brand-black)',
+ border: '1px solid var(--panel-border)',
+ color: 'var(--brand-light-gray)',
+ }}
+ placeholder={t('passwordPlaceholder', language)}
+ required
+ />
- )}
-
- {loading ? t('loading', language) : t('registerButton', language)}
-
-
- )}
+
+
+ {t('confirmPassword', language)}
+
+ setConfirmPassword(e.target.value)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: 'var(--brand-black)',
+ border: '1px solid var(--panel-border)',
+ color: 'var(--brand-light-gray)',
+ }}
+ placeholder={t('confirmPasswordPlaceholder', language)}
+ required
+ />
+
- {step === 'setup-otp' && (
-