+
+
+ {t('environmentSteps.checkTitle', language)}
+
+
-
-
{getShortName(selectedExchange.name)}
-
- {selectedExchange.type.toUpperCase()} • {selectedExchange.id}
+
+
+ {t('environmentSteps.selectTitle', language)}
+
+
setSelectedExchangeId(e.target.value)}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
+ aria-label={t('selectExchange', language)}
+ disabled={webCryptoStatus !== 'secure'}
+ required
+ >
+
+ {t('pleaseSelectExchange', language)}
+
+ {availableExchanges.map((exchange) => (
+
+ {getShortName(exchange.name)} (
+ {exchange.type.toUpperCase()})
+
+ ))}
+
+
+
+ )}
+
+ {selectedExchange && (
+
+
+
+ {getExchangeIcon(selectedExchange.id, {
+ width: 32,
+ height: 32,
+ })}
+
+
+
+ {getShortName(selectedExchange.name)}
+
+
+ {selectedExchange.type.toUpperCase()} •{' '}
+ {selectedExchange.id}
+
-
- )}
+ )}
- {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 && (
+ <>
+ {/* Binance 和其他 CEX 交易所的字段 */}
+ {(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
+ 权限结构不同,会导致订单提交失败
+
- {selectedExchange.id === 'okx' && (
+
+ 正确配置步骤:
+
+
+
+ 登录币安 → 个人中心 →{' '}
+ API 管理
+
+
+ 创建 API → 选择「
+ 系统生成的 API 密钥 」
+
+
+ 勾选「现货与合约交易 」(
+
+ 不选统一账户
+
+ )
+
+
+ IP 限制选「无限制
+ 」或添加服务器 IP
+
+
+
+
+ 💡 多资产模式用户注意:
+ 如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。
+
+
+
+ 📖 查看币安官方教程 ↗
+
+
+ )}
+
+ )}
+
+
+
+ {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
+ />
+
+
+
+
+ {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}
+
+ )}
+ >
+ )}
+
+ {/* Aster 交易所的字段 */}
+ {selectedExchange.id === 'aster' && (
+ <>
-
- {t('passphrase', language)}
+
+ {t('user', language)}
+
+
+
setPassphrase(e.target.value)}
- placeholder={t('enterPassphrase', language)}
+ type="text"
+ value={asterUser}
+ onChange={(e) => 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
/>
- )}
- >
- )}
- {/* Hyperliquid 交易所的字段 */}
- {selectedExchange.id === 'hyperliquid' && (
- <>
-
-
- {t('privateKey', language)}
-
-
setApiKey(e.target.value)}
- placeholder={t('enterPrivateKey', language)}
- className="w-full px-3 py-2 rounded"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
- required
- />
-
- {t('hyperliquidPrivateKeyDesc', language)}
+
+
+ {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',
+ }}
+ required
+ />
-
-
-
- {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)}
+
+
+ {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',
+ }}
+ required
+ />
-
- >
- )}
+ >
+ )}
- {/* Aster 交易所的字段 */}
- {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' }}
- required
- />
-
+ {/* Hyperliquid 交易所的字段 */}
+ {selectedExchange.id === 'hyperliquid' && (
+ <>
+ {/* 安全提示 banner */}
+
+
+
+ 🔐
+
+
+
+ {t('hyperliquidAgentWalletTitle', language)}
+
+
+ {t('hyperliquidAgentWalletDesc', language)}
+
+
+
+
-
-
- {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' }}
- required
- />
-
+ {/* Agent Private Key 字段 */}
+
+
+ {t('hyperliquidAgentPrivateKey', language)}
+
+
+
+
+ setSecureInputTarget('hyperliquid')}
+ className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
+ style={{
+ background: '#F0B90B',
+ color: '#000',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {apiKey
+ ? t('secureInputReenter', language)
+ : t('secureInputButton', language)}
+
+ {apiKey && (
+ setApiKey('')}
+ className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
+ style={{
+ background: '#1B1F2B',
+ color: '#848E9C',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {t('secureInputClear', language)}
+
+ )}
+
+ {apiKey && (
+
+ {t('secureInputHint', language)}
+
+ )}
+
+
+ {t('hyperliquidAgentPrivateKeyDesc', language)}
+
+
-
-
- {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' }}
- required
- />
-
- >
- )}
+ {/* Main Wallet Address 字段 */}
+
+
+ {t('hyperliquidMainWalletAddress', language)}
+
+
+ setHyperliquidWalletAddr(e.target.value)
+ }
+ placeholder={t(
+ 'enterHyperliquidMainWalletAddress',
+ language
+ )}
+ className="w-full px-3 py-2 rounded"
+ style={{
+ background: '#0B0E11',
+ border: '1px solid #2B3139',
+ color: '#EAECEF',
+ }}
+ required
+ />
+
+ {t('hyperliquidMainWalletAddressDesc', language)}
+
+
+ >
+ )}
+ >
+ )}
+
-
-
- setTestnet(e.target.checked)}
- className="form-checkbox rounded"
- style={{ accentColor: '#F0B90B' }}
- />
- {t('useTestnet', language)}
-
-
- {t('testnetDescription', language)}
-
-
-
-
-
-
{t('securityWarning', language)}
-
-
-
{t('exchangeConfigWarning1', language)}
-
{t('exchangeConfigWarning2', language)}
-
{t('exchangeConfigWarning3', language)}
-
-
- >
- )}
-
-
+
+
+ {/* 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)}
+
+
+
+
+
+
+
+ )}
+
+ {/* Two Stage Key Modal */}
+
- );
+ )
}
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.test.tsx b/web/src/components/CompetitionPage.test.tsx
new file mode 100644
index 00000000..6b06139d
--- /dev/null
+++ b/web/src/components/CompetitionPage.test.tsx
@@ -0,0 +1,329 @@
+import { describe, it, expect } from 'vitest'
+
+/**
+ * PR #678 測試: 修復 CompetitionPage 中 NaN 和缺失數據的顯示問題
+ *
+ * 問題:當 total_pnl_pct 為 null/undefined/NaN 時,會顯示 "NaN%" 或 "0.00%"
+ * 修復:檢查數據有效性,顯示 "—" 表示缺失數據
+ */
+
+describe('CompetitionPage - Data Validation Logic (PR #678)', () => {
+ /**
+ * 測試數據有效性檢查邏輯
+ * 這是 PR #678 引入的核心邏輯
+ */
+ describe('hasValidData check', () => {
+ it('should return true for valid numbers', () => {
+ const trader1 = { total_pnl_pct: 10.5 }
+ const trader2 = { total_pnl_pct: -5.2 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(true)
+ })
+
+ it('should return false when trader1 has null value', () => {
+ const trader1 = { total_pnl_pct: null }
+ const trader2 = { total_pnl_pct: 10.5 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct!) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(false)
+ })
+
+ it('should return false when trader2 has undefined value', () => {
+ const trader1 = { total_pnl_pct: 10.5 }
+ const trader2 = { total_pnl_pct: undefined }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct!)
+
+ expect(hasValidData).toBe(false)
+ })
+
+ it('should return false when trader1 has NaN value', () => {
+ const trader1 = { total_pnl_pct: NaN }
+ const trader2 = { total_pnl_pct: 10.5 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(false)
+ })
+
+ it('should return false when both traders have invalid data', () => {
+ const trader1 = { total_pnl_pct: null }
+ const trader2 = { total_pnl_pct: NaN }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct!) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(false)
+ })
+
+ it('should handle zero as valid data', () => {
+ const trader1 = { total_pnl_pct: 0 }
+ const trader2 = { total_pnl_pct: 10.5 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(true)
+ })
+
+ it('should handle negative numbers as valid data', () => {
+ const trader1 = { total_pnl_pct: -15.5 }
+ const trader2 = { total_pnl_pct: -8.2 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ expect(hasValidData).toBe(true)
+ })
+ })
+
+ /**
+ * 測試 gap 計算邏輯
+ * gap 應該只在數據有效時計算
+ */
+ describe('gap calculation', () => {
+ it('should calculate gap correctly for valid data', () => {
+ const trader1 = { total_pnl_pct: 15.5 }
+ const trader2 = { total_pnl_pct: 10.2 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ const gap = hasValidData
+ ? trader1.total_pnl_pct - trader2.total_pnl_pct
+ : NaN
+
+ expect(gap).toBeCloseTo(5.3, 1) // Allow floating point precision
+ expect(isNaN(gap)).toBe(false)
+ })
+
+ it('should return NaN for invalid data', () => {
+ const trader1 = { total_pnl_pct: null }
+ const trader2 = { total_pnl_pct: 10.2 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct!) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ const gap = hasValidData
+ ? trader1.total_pnl_pct! - trader2.total_pnl_pct
+ : NaN
+
+ expect(isNaN(gap)).toBe(true)
+ })
+
+ it('should handle negative gap correctly', () => {
+ const trader1 = { total_pnl_pct: 5.0 }
+ const trader2 = { total_pnl_pct: 12.0 }
+
+ const hasValidData =
+ trader1.total_pnl_pct != null &&
+ trader2.total_pnl_pct != null &&
+ !isNaN(trader1.total_pnl_pct) &&
+ !isNaN(trader2.total_pnl_pct)
+
+ const gap = hasValidData
+ ? trader1.total_pnl_pct - trader2.total_pnl_pct
+ : NaN
+
+ expect(gap).toBe(-7.0)
+ })
+ })
+
+ /**
+ * 測試顯示邏輯
+ * 修復後應顯示「—」而非「NaN%」或「0.00%」
+ */
+ describe('display formatting', () => {
+ it('should format valid positive percentage correctly', () => {
+ const total_pnl_pct = 15.567
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('+15.57%')
+ })
+
+ it('should format valid negative percentage correctly', () => {
+ const total_pnl_pct = -8.234
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('-8.23%')
+ })
+
+ it('should display "—" for null value', () => {
+ const total_pnl_pct = null
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('—')
+ })
+
+ it('should display "—" for undefined value', () => {
+ const total_pnl_pct = undefined
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('—')
+ })
+
+ it('should display "—" for NaN value', () => {
+ const total_pnl_pct = NaN
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('—')
+ })
+
+ it('should format zero correctly', () => {
+ const total_pnl_pct = 0
+
+ const display =
+ total_pnl_pct != null && !isNaN(total_pnl_pct)
+ ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
+ : '—'
+
+ expect(display).toBe('+0.00%')
+ })
+ })
+
+ /**
+ * 測試領先/落後訊息顯示邏輯
+ * 只有在數據有效時才顯示 "領先" 或 "落後" 訊息
+ */
+ describe('leading/trailing message display', () => {
+ it('should show leading message when winning with positive gap', () => {
+ const isWinning = true
+ const gap = 5.2
+ const hasValidData = true
+
+ const shouldShowLeading = hasValidData && isWinning && gap > 0
+
+ expect(shouldShowLeading).toBe(true)
+ })
+
+ it('should not show leading message when data is invalid', () => {
+ const isWinning = true
+ const gap = NaN
+ const hasValidData = false
+
+ const shouldShowLeading = hasValidData && isWinning && gap > 0
+
+ expect(shouldShowLeading).toBe(false)
+ })
+
+ it('should show trailing message when losing with negative gap', () => {
+ const isWinning = false
+ const gap = -3.5
+ const hasValidData = true
+
+ const shouldShowTrailing = hasValidData && !isWinning && gap < 0
+
+ expect(shouldShowTrailing).toBe(true)
+ })
+
+ it('should not show trailing message when data is invalid', () => {
+ const isWinning = false
+ const gap = NaN
+ const hasValidData = false
+
+ const shouldShowTrailing = hasValidData && !isWinning && gap < 0
+
+ expect(shouldShowTrailing).toBe(false)
+ })
+
+ it('should show fallback "—" when data is invalid', () => {
+ const hasValidData = false
+
+ const shouldShowFallback = !hasValidData
+
+ expect(shouldShowFallback).toBe(true)
+ })
+ })
+
+ /**
+ * 測試邊界情況
+ */
+ describe('edge cases', () => {
+ it('should handle very small positive numbers', () => {
+ const total_pnl_pct = 0.001
+
+ const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct)
+
+ expect(hasValidData).toBe(true)
+ })
+
+ it('should handle very large numbers', () => {
+ const total_pnl_pct = 9999.99
+
+ const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct)
+
+ expect(hasValidData).toBe(true)
+ })
+
+ it('should handle Infinity as invalid (produces NaN in calculations)', () => {
+ const total_pnl_pct = Infinity
+
+ // Infinity 本身不是 NaN,但在減法運算中可能導致問題
+ const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct)
+
+ expect(hasValidData).toBe(false)
+ })
+
+ it('should handle -Infinity as invalid', () => {
+ const total_pnl_pct = -Infinity
+
+ const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct)
+
+ expect(hasValidData).toBe(false)
+ })
+ })
+})
diff --git a/web/src/components/CompetitionPage.tsx b/web/src/components/CompetitionPage.tsx
index d719472e..e74d119b 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,101 @@ 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]
+
+ // Check if both values are valid numbers
+ const hasValidData =
+ trader.total_pnl_pct != null &&
+ opponent.total_pnl_pct != null &&
+ !isNaN(trader.total_pnl_pct) &&
+ !isNaN(opponent.total_pnl_pct)
+
+ const gap = hasValidData
+ ? trader.total_pnl_pct - opponent.total_pnl_pct
+ : NaN
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_pct != null &&
+ !isNaN(trader.total_pnl_pct)
+ ? `${trader.total_pnl_pct >= 0 ? '+' : ''}${trader.total_pnl_pct.toFixed(2)}%`
+ : '—'}
- {isWinning && gap > 0 && (
-
+ {hasValidData && isWinning && gap > 0 && (
+
{t('leadingBy', language, { gap: gap.toFixed(2) })}
)}
- {!isWinning && gap < 0 && (
-
- {t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })}
+ {hasValidData && !isWinning && gap < 0 && (
+
+ {t('behindBy', language, {
+ gap: Math.abs(gap).toFixed(2),
+ })}
+
+ )}
+ {!hasValidData && (
+
+ —
)}
- );
+ )
})}
@@ -321,5 +485,5 @@ export function CompetitionPage() {
traderData={selectedTrader}
/>
- );
+ )
}
diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx
new file mode 100644
index 00000000..c2d9b711
--- /dev/null
+++ b/web/src/components/ConfirmDialog.tsx
@@ -0,0 +1,123 @@
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useEffect,
+} from 'react'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogTitle,
+} from './ui/alert-dialog'
+import { setGlobalConfirm } from '../lib/notify'
+
+interface ConfirmOptions {
+ title?: string
+ message: string
+ okText?: string
+ cancelText?: string
+}
+
+interface ConfirmDialogContextType {
+ confirm: (options: ConfirmOptions) => Promise
+}
+
+const ConfirmDialogContext = createContext<
+ ConfirmDialogContextType | undefined
+>(undefined)
+
+export function useConfirmDialog() {
+ const context = useContext(ConfirmDialogContext)
+ if (!context) {
+ throw new Error(
+ 'useConfirmDialog must be used within ConfirmDialogProvider'
+ )
+ }
+ return context
+}
+
+interface ConfirmState {
+ isOpen: boolean
+ title?: string
+ message: string
+ okText: string
+ cancelText: string
+ resolve?: (value: boolean) => void
+}
+
+export function ConfirmDialogProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const [state, setState] = useState({
+ isOpen: false,
+ message: '',
+ okText: '确认',
+ cancelText: '取消',
+ })
+
+ const confirm = useCallback((options: ConfirmOptions): Promise => {
+ return new Promise((resolve) => {
+ setState({
+ isOpen: true,
+ title: options.title,
+ message: options.message,
+ okText: options.okText || '确认',
+ cancelText: options.cancelText || '取消',
+ resolve,
+ })
+ })
+ }, [])
+
+ // 注册全局 confirm 函数
+ useEffect(() => {
+ setGlobalConfirm(confirm)
+ }, [confirm])
+
+ const handleClose = useCallback((result: boolean) => {
+ setState((prev) => {
+ prev.resolve?.(result)
+ return {
+ ...prev,
+ isOpen: false,
+ }
+ })
+ }, [])
+
+ return (
+
+ {children}
+ !open && handleClose(false)}
+ >
+
+
+ {state.title && (
+
+ {state.title}
+
+ )}
+
+ {state.message}
+
+
+
+ handleClose(false)}>
+ {state.cancelText}
+
+ handleClose(true)}>
+ {state.okText}
+
+
+
+
+
+ )
+}
diff --git a/web/src/components/Container.tsx b/web/src/components/Container.tsx
new file mode 100644
index 00000000..00ae716d
--- /dev/null
+++ b/web/src/components/Container.tsx
@@ -0,0 +1,40 @@
+import { ReactNode, CSSProperties } from 'react'
+
+interface ContainerProps {
+ children: ReactNode
+ className?: string
+ as?: 'div' | 'main' | 'header' | 'section'
+ style?: CSSProperties
+ /** 是否充满宽度(取消 max-width) */
+ fluid?: boolean
+ /** 是否取消水平内边距 */
+ noPadding?: boolean
+ /** 自定义最大宽度类(默认 max-w-[1920px]) */
+ maxWidthClass?: string
+}
+
+/**
+ * 统一的容器组件,确保所有页面元素使用一致的最大宽度和内边距
+ * - max-width: 1920px
+ * - padding: 24px (mobile) -> 32px (tablet) -> 48px (desktop)
+ */
+export function Container({
+ children,
+ className = '',
+ as: Component = 'div',
+ style,
+ fluid = false,
+ noPadding = false,
+ maxWidthClass = 'max-w-[1920px]',
+}: ContainerProps) {
+ const maxWidth = fluid ? 'w-full' : maxWidthClass
+ const padding = noPadding ? 'px-0' : 'px-6 sm:px-8 lg:px-12'
+ return (
+
+ {children}
+
+ )
+}
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/DevToastController.tsx b/web/src/components/DevToastController.tsx
new file mode 100644
index 00000000..912e3723
--- /dev/null
+++ b/web/src/components/DevToastController.tsx
@@ -0,0 +1,116 @@
+///
+
+import { useState } from 'react'
+import { confirmToast, notify } from '../lib/notify'
+
+const toastOptions = [
+ 'message',
+ 'success',
+ 'info',
+ 'warning',
+ 'error',
+ 'custom',
+] as const
+
+type ToastType = (typeof toastOptions)[number]
+
+const customRenderer = () => (
+
+
Sonner 自定义通知
+
+ 这是一个通过 `notify.custom` 渲染的测试 Toast
+
+
+)
+
+export function DevToastController() {
+ const [type, setType] = useState
('success')
+ const [message, setMessage] = useState('来自 Dev 控制器的测试通知')
+ const [duration, setDuration] = useState(2200)
+
+ if (!import.meta.env.DEV) {
+ return null
+ }
+
+ const triggerToast = async () => {
+ switch (type) {
+ case 'message':
+ notify.message(message, { duration })
+ break
+ case 'success':
+ notify.success(message, { duration })
+ break
+ case 'info':
+ notify.info(message, { duration })
+ break
+ case 'warning':
+ notify.warning(message, { duration })
+ break
+ case 'error':
+ notify.error(message, { duration })
+ break
+ case 'custom':
+ notify.custom(() => customRenderer(), { duration })
+ break
+ }
+ }
+
+ const triggerConfirm = async () => {
+ const confirmed = await confirmToast(message, {
+ okText: '继续',
+ cancelText: '取消',
+ })
+ if (confirmed) {
+ notify.success('确认按钮已点击', { duration: 2000 })
+ } else {
+ notify.message('已取消确认逻辑', { duration: 2000 })
+ }
+ }
+
+ return (
+
+
+ Dev Sonner 控制器
+ 仅在 dev 模式可见
+
+
+
+ 类型
+ setType(event.target.value as ToastType)}
+ >
+ {toastOptions.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+ 文案
+ setMessage(event.target.value)}
+ placeholder="输入通知/确认文案"
+ />
+
+
+ 持续(ms)
+ setDuration(Number(event.target.value))}
+ />
+
+
+ 触发通知
+ 触发确认
+
+
+
+ )
+}
+
+export default DevToastController
diff --git a/web/src/components/EquityChart.tsx b/web/src/components/EquityChart.tsx
index e7441779..f8beb1d5 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,65 +8,74 @@ 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 { useAuth } from '../contexts/AuthContext'
+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 { user, token } = useAuth()
+ const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR(
- traderId ? `equity-history-${traderId}` : 'equity-history',
+ user && token && traderId ? `equity-history-${traderId}` : null,
() => api.getEquityHistory(traderId),
{
refreshInterval: 30000, // 30秒刷新(历史数据更新频率较低)
revalidateOnFocus: false,
dedupingInterval: 20000,
}
- );
+ )
const { data: account } = useSWR(
- traderId ? `account-${traderId}` : 'account',
+ user && token && traderId ? `account-${traderId}` : null,
() => api.getAccount(traderId),
{
refreshInterval: 15000, // 15秒刷新(配合后端缓存)
revalidateOnFocus: false,
dedupingInterval: 10000,
}
- );
+ )
if (error) {
return (
-
+
-
+
-
+
{t('loadingError', language)}
-
@@ -76,22 +85,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 +108,24 @@ 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 +136,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 +187,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%)
- );
+ )
}
- return null;
- };
+ return null
+ }
return (
-