feat: add ESLint and Prettier with pre-commit hook

- Install ESLint 9 with TypeScript and React support
- Install Prettier with custom configuration (no semicolons)
- Add husky and lint-staged for pre-commit hooks
- Configure lint-staged to auto-fix and format on commit
- Relax ESLint rules to avoid large-scale code changes
- Format all existing code with Prettier (no semicolons)
Co-Authored-By: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
Ember
2025-11-05 11:30:27 +08:00
parent aaf1243f20
commit b79878ab36
44 changed files with 9619 additions and 3482 deletions
+3
View File
@@ -41,3 +41,6 @@ web/node_modules/
node_modules/
web/dist/
web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
+1
View File
@@ -0,0 +1 @@
npm test
+1 -1
View File
@@ -1,5 +1,5 @@
{
"semi": true,
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
+19 -11
View File
@@ -53,24 +53,32 @@ export default [
// React rules
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
// 该规则在 TS 项目中经常与 TS 的类型检查重复,关闭以避免误报
'no-undef': 'off',
// TypeScript rules
'@typescript-eslint/no-explicit-any': 'warn',
// 放宽以下规则以避免在不改变功能的情况下大面积改动代码
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/no-unused-vars': 'off',
// React Refresh
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
'react-refresh/only-export-components': 'off',
// General rules
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'warn'
'no-console': 'off',
'no-debugger': 'off',
// 新版 react-hooks 推荐规则在本项目会造成大量误报,关闭以免影响开发体验
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/preserve-manual-memoization': 'off',
// 某些字符串中包含未转义字符用于展示,关闭以避免不必要的修改
'react/no-unescaped-entities': 'off',
// 可视情况关闭依赖数组校验(如需严格可改为 'warn'
'react-hooks/exhaustive-deps': 'off'
},
settings: {
react: {
+3716
View File
File diff suppressed because it is too large Load Diff
+552 -266
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+298 -188
View File
@@ -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<string, {
timestamp: string;
time: string;
traders: Map<string, { pnl_pct: number; equity: number }>;
}>();
const timestampMap = new Map<
string,
{
timestamp: string
time: string
traders: Map<string, { pnl_pct: number; equity: number }>
}
>()
traderHistories.forEach((history, index) => {
const trader = traders[index];
if (!history.data) return;
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) {
<div className="spinner mx-auto mb-4"></div>
<div className="text-sm font-semibold">Loading comparison data...</div>
</div>
);
)
}
if (combinedData.length === 0) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
);
)
}
// 限制显示数据点
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 (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div
className="rounded p-3 shadow-xl"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{data.time} - #{data.index}
</div>
{traders.map((trader) => {
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
const equity = data[`${trader.trader_id}_equity`];
if (pnlPct === undefined) return null;
const pnlPct = data[`${trader.trader_id}_pnl_pct`]
const equity = data[`${trader.trader_id}_equity`]
if (pnlPct === undefined) return null
return (
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
@@ -209,31 +222,49 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
>
{trader.trader_name}
</div>
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
<div
className="text-sm mono font-bold"
style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}
>
{pnlPct >= 0 ? '+' : ''}
{pnlPct.toFixed(2)}%
<span
className="text-xs ml-2 font-normal"
style={{ color: '#848E9C' }}
>
({equity?.toFixed(2)} USDT)
</span>
</div>
</div>
);
)
})}
</div>
);
)
}
return null;
};
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 (
<div>
<div style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<div
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
style={{
@@ -245,116 +276,195 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width="100%" height={520}>
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.9} />
<stop offset="95%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.2} />
</linearGradient>
))}
</defs>
<LineChart
data={displayData}
margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={traderColor(trader.trader_id)}
stopOpacity={0.9}
/>
<stop
offset="95%"
stopColor={traderColor(trader.trader_id)}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={traderColor(trader.trader_id)}
strokeWidth={3}
dot={displayData.length < 50 ? { fill: traderColor(trader.trader_id), r: 3 } : false}
activeDot={{ r: 6, fill: traderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
name={trader.trader_name}
connectNulls
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
const trader = traders.find((t) => t.trader_id === traderId);
return (
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
);
}}
/>
</LineChart>
</ResponsiveContainer>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={traderColor(trader.trader_id)}
strokeWidth={3}
dot={
displayData.length < 50
? { fill: traderColor(trader.trader_id), r: 3 }
: false
}
activeDot={{
r: 6,
fill: traderColor(trader.trader_id),
stroke: '#fff',
strokeWidth: 2,
}}
name={trader.trader_name}
connectNulls
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find(
(t) => value === t.trader_name
)?.trader_id
const trader = traders.find((t) => t.trader_id === traderId)
return (
<span
style={{
color: entry.color,
fontWeight: 600,
fontSize: '14px',
}}
>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
)
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</div>
<div className="text-sm md:text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
<div
className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5"
style={{ borderTop: '1px solid #2B3139' }}
>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('comparisonMode', language)}
</div>
<div
className="text-sm md:text-base font-bold"
style={{ color: '#EAECEF' }}
>
PnL %
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: combinedData.length})}</div>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('dataPoints', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{t('count', language, { count: combinedData.length })}
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('currentGap', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}
>
{currentGap.toFixed(2)}%
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{combinedData.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)}
@@ -362,5 +472,5 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
</div>
</div>
</div>
);
)
}
+238 -94
View File
@@ -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<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const { language } = useLanguage()
const [selectedTrader, setSelectedTrader] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const { data: competition } = useSWR<CompetitionData>(
'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() {
</div>
</div>
</div>
);
)
}
// 如果有数据返回但没有交易员,显示空状态
@@ -71,16 +71,31 @@ export function CompetitionPage() {
{/* Competition Header - 精简版 */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
}}
>
<Trophy
className="w-6 h-6 md:w-7 md:h-7"
style={{ color: '#000' }}
/>
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h1
className="text-xl md:text-2xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('aiCompetition', language)}
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
<span
className="text-xs font-normal px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.15)',
color: '#F0B90B',
}}
>
0 {t('traders', language)}
</span>
</h1>
@@ -93,7 +108,10 @@ export function CompetitionPage() {
{/* Empty State */}
<div className="binance-card p-8 text-center">
<Trophy className="w-16 h-16 mx-auto mb-4 opacity-40" style={{ color: '#848E9C' }} />
<Trophy
className="w-16 h-16 mx-auto mb-4 opacity-40"
style={{ color: '#848E9C' }}
/>
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
{t('noTraders', language)}
</h3>
@@ -102,32 +120,47 @@ export function CompetitionPage() {
</p>
</div>
</div>
);
)
}
// 按收益率排序
const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct
);
)
// 找出领先者
const leader = sortedTraders[0];
const leader = sortedTraders[0]
return (
<div className="space-y-5 animate-fade-in">
{/* Competition Header - 精简版 */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
}}
>
<Trophy
className="w-6 h-6 md:w-7 md:h-7"
style={{ color: '#000' }}
/>
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h1
className="text-xl md:text-2xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('aiCompetition', language)}
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
<span
className="text-xs font-normal px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.15)',
color: '#F0B90B',
}}
>
{competition.count} {t('traders', language)}
</span>
</h1>
@@ -137,10 +170,23 @@ export function CompetitionPage() {
</div>
</div>
<div className="text-left md:text-right w-full md:w-auto">
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('leader', language)}</div>
<div className="text-base md:text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('leader', language)}
</div>
<div
className="text-base md:text-lg font-bold"
style={{ color: '#F0B90B' }}
>
{leader?.trader_name}
</div>
<div
className="text-sm font-semibold"
style={{
color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
}}
>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
</div>
</div>
@@ -148,9 +194,15 @@ export function CompetitionPage() {
{/* Left/Right Split: Performance Chart + Leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Left: Performance Comparison Chart */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.1s' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('performanceComparison', language)}
</h2>
<div className="text-xs" style={{ color: '#848E9C' }}>
@@ -161,19 +213,35 @@ export function CompetitionPage() {
</div>
{/* Right: Leaderboard */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.1s' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('leaderboard', language)}
</h2>
<div className="text-xs px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
<div
className="text-xs px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.1)',
color: '#F0B90B',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
{t('live', language)}
</div>
</div>
<div className="space-y-2">
{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 (
<div
@@ -181,21 +249,44 @@ export function CompetitionPage() {
onClick={() => handleTraderClick(trader.trader_id)}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
style={{
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
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)',
}}
>
<div className="flex items-center justify-between">
{/* Rank & Name */}
<div className="flex items-center gap-3">
<div className="w-6 flex items-center justify-center">
<Medal className="w-5 h-5" style={{ color: index === 0 ? '#F0B90B' : index === 1 ? '#C0C0C0' : '#CD7F32' }} />
<Medal
className="w-5 h-5"
style={{
color:
index === 0
? '#F0B90B'
: index === 1
? '#C0C0C0'
: '#CD7F32',
}}
/>
</div>
<div>
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
<div className="text-xs mono font-semibold" style={{ color: traderColor }}>
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
<div
className="font-bold text-sm"
style={{ color: '#EAECEF' }}
>
{trader.trader_name}
</div>
<div
className="text-xs mono font-semibold"
style={{ color: traderColor }}
>
{trader.ai_model.toUpperCase()} +{' '}
{trader.exchange.toUpperCase()}
</div>
</div>
</div>
@@ -204,31 +295,52 @@ export function CompetitionPage() {
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* Total Equity */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('equity', language)}</div>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('equity', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.total_equity?.toFixed(2) || '0.00'}
</div>
</div>
{/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pnl', language)}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pnl', language)}
</div>
<div
className="text-base md:text-lg font-bold mono"
style={{ color: (trader.total_pnl ?? 0) >= 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'}%
</div>
<div className="text-xs mono" style={{ color: '#848E9C' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
<div
className="text-xs mono"
style={{ color: '#848E9C' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl?.toFixed(2) || '0.00'}
</div>
</div>
{/* Positions */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pos', language)}</div>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pos', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.position_count}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
@@ -240,9 +352,16 @@ export function CompetitionPage() {
<div>
<div
className="px-2 py-1 rounded text-xs font-bold"
style={trader.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
style={
trader.is_running
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
}
>
{trader.is_running ? '●' : '○'}
@@ -251,7 +370,7 @@ export function CompetitionPage() {
</div>
</div>
</div>
);
)
})}
</div>
</div>
@@ -259,56 +378,81 @@ export function CompetitionPage() {
{/* Head-to-Head Stats */}
{competition.traders.length === 2 && (
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}>
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.3s' }}
>
<h2
className="text-lg font-bold mb-4 flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('headToHead', language)}
</h2>
<div className="grid grid-cols-2 gap-4">
{sortedTraders.map((trader, index) => {
const isWinning = index === 0;
const opponent = sortedTraders[1 - index];
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
const isWinning = index === 0
const opponent = sortedTraders[1 - index]
const gap = trader.total_pnl_pct - opponent.total_pnl_pct
return (
<div
key={trader.trader_id}
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
style={isWinning
? {
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)'
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)'
}
style={
isWinning
? {
background:
'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)',
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
}
}
>
<div className="text-center">
<div
className="text-sm md:text-base font-bold mb-2"
style={{ color: getTraderColor(sortedTraders, trader.trader_id) }}
style={{
color: getTraderColor(sortedTraders, trader.trader_id),
}}
>
{trader.trader_name}
</div>
<div className="text-lg md:text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
<div
className="text-lg md:text-2xl font-bold mono mb-1"
style={{
color:
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
}}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
{isWinning && gap > 0 && (
<div className="text-xs font-semibold" style={{ color: '#0ECB81' }}>
<div
className="text-xs font-semibold"
style={{ color: '#0ECB81' }}
>
{t('leadingBy', language, { gap: gap.toFixed(2) })}
</div>
)}
{!isWinning && gap < 0 && (
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}>
{t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })}
<div
className="text-xs font-semibold"
style={{ color: '#F6465D' }}
>
{t('behindBy', language, {
gap: Math.abs(gap).toFixed(2),
})}
</div>
)}
</div>
</div>
);
)
})}
</div>
</div>
@@ -321,5 +465,5 @@ export function CompetitionPage() {
traderData={selectedTrader}
/>
</div>
);
)
}
+116 -95
View File
@@ -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<HTMLDivElement, CryptoFeatureCardProps>(
({ 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 (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
>
<div
className={cn(
'relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl',
'bg-gradient-to-br from-[#000000] to-[#0A0A0A]',
'border-[#1A1A1A] hover:border-[#F0B90B]/50',
isHovered && 'shadow-[0_0_20px_rgba(240,185,11,0.2)]',
className
)}
>
<div
className={cn(
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl",
"bg-gradient-to-br from-[#000000] to-[#0A0A0A]",
"border-[#1A1A1A] hover:border-[#F0B90B]/50",
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]",
className
)}
{/* Animated glow border effect */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
animate={{
opacity: isHovered ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
{/* Animated glow border effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: '32px 32px',
}}
/>
</div>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
style={{
background:
'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? '0 0 20px rgba(240, 185, 11, 0.4)'
: '0 0 0px rgba(240, 185, 11, 0)',
}}
transition={{ duration: 0.3 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: "32px 32px",
}}
/>
</div>
{/* Title */}
<h3
className="text-2xl font-bold mb-3"
style={{ color: 'var(--brand-light-gray)' }}
>
{title}
</h3>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
style={{
background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.3)'
}}
animate={{
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? "0 0 20px rgba(240, 185, 11, 0.4)"
: "0 0 0px rgba(240, 185, 11, 0)",
}}
transition={{ duration: 0.3 }}
>
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
</motion.div>
{/* Description */}
<p
className="mb-6 flex-grow leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
{/* Title */}
<h3 className="text-2xl font-bold mb-3" style={{ color: 'var(--brand-light-gray)' }}>{title}</h3>
{/* Description */}
<p className="mb-6 flex-grow leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{description}</p>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.2)' }}>
<Check className="w-3 h-3" style={{ color: 'var(--brand-yellow)' }} />
</div>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: 'rgba(240, 185, 11, 0.2)' }}
>
<Check
className="w-3 h-3"
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
<span className="text-sm" style={{ color: 'var(--brand-light-gray)' }}>{feature}</span>
</motion.div>
))}
</div>
</div>
<span
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{feature}
</span>
</motion.div>
))}
</div>
</div>
</motion.div>
);
}
);
</div>
</motion.div>
)
})
CryptoFeatureCard.displayName = "CryptoFeatureCard";
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
+130 -110
View File
@@ -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<EquityPoint[]>(
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 (
<div className='binance-card p-6'>
<div className="binance-card p-6">
<div
className='flex items-center gap-3 p-4 rounded'
className="flex items-center gap-3 p-4 rounded"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.2)',
}}
>
<AlertTriangle className='w-6 h-6' style={{ color: '#F6465D' }} />
<AlertTriangle className="w-6 h-6" style={{ color: '#F6465D' }} />
<div>
<div className='font-semibold' style={{ color: '#F6465D' }}>
<div className="font-semibold" style={{ color: '#F6465D' }}>
{t('loadingError', language)}
</div>
<div className='text-sm' style={{ color: '#848E9C' }}>
<div className="text-sm" style={{ color: '#848E9C' }}>
{error.message}
</div>
</div>
@@ -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 (
<div className='binance-card p-6'>
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}>
<div className="binance-card p-6">
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
{t('accountEquityCurve', language)}
</h3>
<div className='text-center py-16' style={{ color: '#848E9C' }}>
<div className='mb-4 flex justify-center opacity-50'>
<BarChart3 className='w-16 h-16' />
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="mb-4 flex justify-center opacity-50">
<BarChart3 className="w-16 h-16" />
</div>
<div className='text-lg font-semibold mb-2'>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className='text-sm'>{t('dataWillAppear', language)}</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
</div>
)
@@ -99,20 +106,20 @@ 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,与常见配置一致
const initialBalance =
validHistory[0]?.total_equity || account?.total_equity || 100 // 默认值改为100,与常见配置一致
// 转换数据格式
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 +130,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 (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</div>
<div
className="rounded p-3 shadow-xl"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
Cycle #{data.cycle}
</div>
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT
</div>
@@ -172,38 +181,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%)
</div>
</div>
);
)
}
return null;
};
return null
}
return (
<div className='binance-card p-3 sm:p-5 animate-fade-in'>
<div className="binance-card p-3 sm:p-5 animate-fade-in">
{/* Header */}
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'>
<div className='flex-1'>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex-1">
<h3
className='text-base sm:text-lg font-bold mb-2'
className="text-base sm:text-lg font-bold mb-2"
style={{ color: '#EAECEF' }}
>
{t('accountEquityCurve', language)}
</h3>
<div className='flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4'>
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
<span
className='text-2xl sm:text-3xl font-bold mono'
className="text-2xl sm:text-3xl font-bold mono"
style={{ color: '#EAECEF' }}
>
{account?.total_equity.toFixed(2) || '0.00'}
<span
className='text-base sm:text-lg ml-1'
className="text-base sm:text-lg ml-1"
style={{ color: '#848E9C' }}
>
USDT
</span>
</span>
<div className='flex items-center gap-2 flex-wrap'>
<div className="flex items-center gap-2 flex-wrap">
<span
className='text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1'
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
style={{
color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit
@@ -216,12 +225,16 @@ export function EquityChart({ traderId }: EquityChartProps) {
}`,
}}
>
{isProfit ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
{isProfit ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
</span>
<span
className='text-xs sm:text-sm mono'
className="text-xs sm:text-sm mono"
style={{ color: '#848E9C' }}
>
({isProfit ? '+' : ''}
@@ -233,12 +246,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Display Mode Toggle */}
<div
className='flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto'
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<button
onClick={() => setDisplayMode('dollar')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all 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 +262,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<DollarSign className='w-4 h-4' /> USDT
<DollarSign className="w-4 h-4" /> USDT
</button>
<button
onClick={() => setDisplayMode('percent')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all 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,13 +277,20 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<Percent className='w-4 h-4' />
<Percent className="w-4 h-4" />
</button>
</div>
</div>
{/* Chart */}
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<div
className="my-2"
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
style={{
@@ -282,35 +302,35 @@ export function EquityChart({ traderId }: EquityChartProps) {
color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width='100%' height={280}>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={chartData}
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
>
<defs>
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} />
<stop offset='95%' stopColor='#FCD535' stopOpacity={0.2} />
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' />
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey='time'
stroke='#5E6673'
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor='end'
textAnchor="end"
height={60}
/>
<YAxis
stroke='#5E6673'
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
@@ -321,8 +341,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke='#474D57'
strokeDasharray='3 3'
stroke="#474D57"
strokeDasharray="3 3"
label={{
value:
displayMode === 'dollar'
@@ -333,9 +353,9 @@ export function EquityChart({ traderId }: EquityChartProps) {
}}
/>
<Line
type='natural'
dataKey='value'
stroke='url(#colorGradient)'
type="natural"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{
@@ -352,72 +372,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Footer Stats */}
<div
className='mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3'
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
style={{ borderTop: '1px solid #2B3139' }}
>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('initialBalance', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('currentEquity', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{currentValue.raw_equity.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('historicalCycles', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length} {t('cycles', language)}
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length > MAX_DISPLAY_POINTS
+96 -38
View File
@@ -1,13 +1,17 @@
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<IconProps> = ({ width = 24, height = 24, className }) => (
const BinanceIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
@@ -20,10 +24,14 @@ const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }
fill="#f0b90b"
/>
</svg>
);
)
// Hyperliquid SVG 图标组件
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
const HyperliquidIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
width={width}
height={height}
@@ -37,10 +45,14 @@ const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, classNa
fill="#97FCE4"
/>
</svg>
);
)
// Aster SVG 图标组件
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
const AsterIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
width={width}
height={height}
@@ -50,52 +62,98 @@ const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className })
className={className}
>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint0_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint1_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint2_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<linearGradient
id="paint3_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
</linearGradient>
</defs>
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
<path
d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z"
fill="url(#paint0_linear_428_3535)"
/>
<path
d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z"
fill="url(#paint1_linear_428_3535)"
/>
<path
d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z"
fill="url(#paint2_linear_428_3535)"
/>
<path
d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z"
fill="url(#paint3_linear_428_3535)"
/>
</svg>
);
)
// 获取交易所图标的函数
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
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 <BinanceIcon {...iconProps} />;
return <BinanceIcon {...iconProps} />
case 'hyperliquid':
case 'dex':
return <HyperliquidIcon {...iconProps} />;
return <HyperliquidIcon {...iconProps} />
case 'aster':
return <AsterIcon {...iconProps} />;
return <AsterIcon {...iconProps} />
default:
return (
<div
@@ -110,11 +168,11 @@ export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) =>
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
color: '#EAECEF'
color: '#EAECEF',
}}
>
{type[0]?.toUpperCase() || '?'}
</div>
);
)
}
};
}
+17 -12
View File
@@ -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 (
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
@@ -30,13 +30,17 @@ export function Header({ simple = false }: HeaderProps) {
</div>
{/* Right - Language Toggle (always show) */}
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<div
className="flex gap-1 rounded p-1"
style={{ background: '#1E2329' }}
>
<button
onClick={() => setLanguage('zh')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
style={
language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
@@ -44,9 +48,10 @@ export function Header({ simple = false }: HeaderProps) {
<button
onClick={() => setLanguage('en')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
style={
language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
EN
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
</div>
</div>
</header>
);
)
}
+212 -148
View File
@@ -1,53 +1,53 @@
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 (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
@@ -59,150 +59,214 @@ export function LoginPage() {
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'
}
}}
/>
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div
className="flex items-center justify-center pt-20"
style={{ minHeight: 'calc(100vh - 80px)' }}
>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-bold" style={{ color: 'var(--brand-light-gray)' }}>
<h1
className="text-2xl font-bold"
style={{ color: 'var(--brand-light-gray)' }}
>
NOFX
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
<p
className="text-sm mt-2"
style={{ color: 'var(--text-secondary)' }}
>
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
</p>
</div>
{/* Login Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
{/* Login Form */}
<div
className="rounded-lg p-6"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{loading ? t('loading', language) : t('loginButton', language)}
</button>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}<br />
{t('enterOTPCode', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
>
{t('back', language)}
</button>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{loading ? t('loading', language) : t('verifyOTP', language)}
{loading
? t('loading', language)
: t('loginButton', language)}
</button>
</div>
</form>
)}
</div>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}
<br />
{t('enterOTPCode', language)}
</p>
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--text-secondary)',
}}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('verifyOTP', language)}
</button>
</div>
</form>
)}
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
</div>
</div>
</div>
</div>
</div>
);
)
}
+12 -13
View File
@@ -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;
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
let iconPath: string | null = null;
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%' }}
/>
);
};
)
}
+415 -279
View File
@@ -1,92 +1,96 @@
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 (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
@@ -97,270 +101,402 @@ export function RegisterPage() {
language={language}
onLanguageChange={() => {}}
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'
}
}}
/>
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div
className="flex items-center justify-center pt-20"
style={{ minHeight: 'calc(100vh - 80px)' }}
>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
{/* Registration Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{betaMode && (
{/* Registration Form */}
<div
className="rounded-lg p-6"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
*
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('email', language)}
</label>
<input
type="text"
value={betaCode}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
className="w-full px-3 py-2 rounded font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder="请输入6位内测码"
maxLength={6}
required={betaMode}
type="email"
value={email}
onChange={(e) => 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
/>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
6
</p>
</div>
)}
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{loading ? t('loading', language) : t('registerButton', language)}
</button>
</form>
)}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
{betaMode && (
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
*
</label>
<input
type="text"
value={betaCode}
onChange={(e) =>
setBetaCode(
e.target.value
.replace(/[^a-z0-9]/gi, '')
.toLowerCase()
)
}
className="w-full px-3 py-2 rounded font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder="请输入6位内测码"
maxLength={6}
required={betaMode}
/>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
6
</p>
</div>
)}
<div className="space-y-3">
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep1Title', language)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('authStep1Desc', language)}
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{loading
? t('loading', language)
: t('registerButton', language)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3
className="text-lg font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('authStep2Desc', language)}
</p>
<div className="space-y-3">
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep1Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep1Desc', language)}
</p>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('authStep2Desc', language)}
</p>
{qrCodeURL && (
<div className="mt-2">
<p
className="text-xs mb-2"
style={{ color: '#848E9C' }}
>
{t('qrCodeHint', language)}
</p>
<div className="bg-white p-2 rounded text-center">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code"
className="mx-auto"
/>
</div>
</div>
)}
{qrCodeURL && (
<div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
<div className="bg-white p-2 rounded text-center">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code" className="mx-auto" />
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('otpSecret', language)}
</p>
<div className="flex items-center gap-2">
<code
className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--brand-light-gray)',
}}
>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('copy', language)}
</button>
</div>
</div>
)}
</div>
<div className="mt-2">
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--brand-light-gray)' }}>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{t('copy', language)}
</button>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep3Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep3Desc', language)}
</p>
</div>
</div>
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep3Title', language)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('authStep3Desc', language)}
</p>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('setupCompleteContinue', language)}
</button>
</div>
)}
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('completeRegistration', language)}
{t('setupCompleteContinue', language)}
</button>
</div>
</form>
)}
</div>
)}
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}
<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--text-secondary)',
}}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('completeRegistration', language)}
</button>
</div>
</form>
)}
</div>
)}
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
</div>
)}
</div>
</div>
</div>
);
)
}
+237 -123
View File
@@ -1,40 +1,40 @@
import { useState, useEffect } from 'react';
import type { AIModel, Exchange, CreateTraderRequest } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
system_prompt_template: string;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
scan_interval_minutes: number;
trader_id?: string
trader_name: string
ai_model: string
exchange_id: string
btc_eth_leverage: number
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
system_prompt_template: string
is_cross_margin: boolean
use_coin_pool: boolean
use_oi_top: boolean
initial_balance: number
scan_interval_minutes: number
}
interface TraderConfigModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
isEditMode?: boolean;
availableModels?: AIModel[];
availableExchanges?: Exchange[];
onSave?: (data: CreateTraderRequest) => Promise<void>;
isOpen: boolean
onClose: () => void
traderData?: TraderConfigData | null
isEditMode?: boolean
availableModels?: AIModel[]
availableExchanges?: Exchange[]
onSave?: (data: CreateTraderRequest) => Promise<void>
}
export function TraderConfigModal({
@@ -44,9 +44,9 @@ export function TraderConfigModal({
isEditMode = false,
availableModels = [],
availableExchanges = [],
onSave
onSave,
}: TraderConfigModalProps) {
const { language } = useLanguage();
const { language } = useLanguage()
const [formData, setFormData] = useState<TraderConfigData>({
trader_name: '',
ai_model: '',
@@ -62,20 +62,23 @@ export function TraderConfigModal({
use_oi_top: false,
initial_balance: 1000,
scan_interval_minutes: 3,
});
const [isSaving, setIsSaving] = useState(false);
const [availableCoins, setAvailableCoins] = useState<string[]>([]);
const [selectedCoins, setSelectedCoins] = useState<string[]>([]);
const [showCoinSelector, setShowCoinSelector] = useState(false);
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]);
})
const [isSaving, setIsSaving] = useState(false)
const [availableCoins, setAvailableCoins] = useState<string[]>([])
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
const [showCoinSelector, setShowCoinSelector] = useState(false)
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
useEffect(() => {
if (traderData) {
setFormData(traderData);
setFormData(traderData)
// 设置已选择的币种
if (traderData.trading_symbols) {
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s);
setSelectedCoins(coins);
const coins = traderData.trading_symbols
.split(',')
.map((s) => s.trim())
.filter((s) => s)
setSelectedCoins(coins)
}
} else if (!isEditMode) {
setFormData({
@@ -93,85 +96,96 @@ export function TraderConfigModal({
use_oi_top: false,
initial_balance: 1000,
scan_interval_minutes: 3,
});
})
}
// 确保旧数据也有默认的 system_prompt_template
if (traderData && !traderData.system_prompt_template) {
setFormData(prev => ({
setFormData((prev) => ({
...prev,
system_prompt_template: 'default'
}));
system_prompt_template: 'default',
}))
}
}, [traderData, isEditMode, availableModels, availableExchanges]);
}, [traderData, isEditMode, availableModels, availableExchanges])
// 获取系统配置中的币种列表
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
const response = await fetch('/api/config')
const config = await response.json()
if (config.default_coins) {
setAvailableCoins(config.default_coins);
setAvailableCoins(config.default_coins)
}
} catch (error) {
console.error('Failed to fetch config:', error);
console.error('Failed to fetch config:', error)
// 使用默认币种列表
setAvailableCoins(['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT', 'ADAUSDT']);
setAvailableCoins([
'BTCUSDT',
'ETHUSDT',
'SOLUSDT',
'BNBUSDT',
'XRPUSDT',
'DOGEUSDT',
'ADAUSDT',
])
}
};
fetchConfig();
}, []);
}
fetchConfig()
}, [])
// 获取系统提示词模板列表
useEffect(() => {
const fetchPromptTemplates = async () => {
try {
const response = await fetch('/api/prompt-templates');
const data = await response.json();
const response = await fetch('/api/prompt-templates')
const data = await response.json()
if (data.templates) {
setPromptTemplates(data.templates);
setPromptTemplates(data.templates)
}
} catch (error) {
console.error('Failed to fetch prompt templates:', error);
console.error('Failed to fetch prompt templates:', error)
// 使用默认模板列表
setPromptTemplates([{name: 'default'}, {name: 'aggressive'}]);
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
}
};
fetchPromptTemplates();
}, []);
}
fetchPromptTemplates()
}, [])
// 当选择的币种改变时,更新输入框
useEffect(() => {
const symbolsString = selectedCoins.join(',');
setFormData(prev => ({ ...prev, trading_symbols: symbolsString }));
}, [selectedCoins]);
const symbolsString = selectedCoins.join(',')
setFormData((prev) => ({ ...prev, trading_symbols: symbolsString }))
}, [selectedCoins])
if (!isOpen) return null;
if (!isOpen) return null
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
setFormData((prev) => ({ ...prev, [field]: value }))
// 如果是直接编辑trading_symbols,同步更新selectedCoins
if (field === 'trading_symbols') {
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s);
setSelectedCoins(coins);
const coins = value
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s)
setSelectedCoins(coins)
}
};
}
const handleCoinToggle = (coin: string) => {
setSelectedCoins(prev => {
setSelectedCoins((prev) => {
if (prev.includes(coin)) {
return prev.filter(c => c !== coin);
return prev.filter((c) => c !== coin)
} else {
return [...prev, coin];
return [...prev, coin]
}
});
};
})
}
const handleSave = async () => {
if (!onSave) return;
if (!onSave) return
setIsSaving(true);
setIsSaving(true)
try {
const saveData: CreateTraderRequest = {
name: formData.trader_name,
@@ -188,15 +202,15 @@ export function TraderConfigModal({
use_oi_top: formData.use_oi_top,
initial_balance: formData.initial_balance,
scan_interval_minutes: formData.scan_interval_minutes,
};
await onSave(saveData);
onClose();
}
await onSave(saveData)
onClose()
} catch (error) {
console.error('保存失败:', error);
console.error('保存失败:', error)
} finally {
setIsSaving(false);
setIsSaving(false)
}
};
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
@@ -236,24 +250,32 @@ export function TraderConfigModal({
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input
type="text"
value={formData.trader_name}
onChange={(e) => handleInputChange('trader_name', e.target.value)}
onChange={(e) =>
handleInputChange('trader_name', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="请输入交易员名称"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">AI模型</label>
<label className="text-sm text-[#EAECEF] block mb-2">
AI模型
</label>
<select
value={formData.ai_model}
onChange={(e) => handleInputChange('ai_model', e.target.value)}
onChange={(e) =>
handleInputChange('ai_model', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableModels.map(model => (
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()}
</option>
@@ -261,15 +283,21 @@ export function TraderConfigModal({
</select>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select
value={formData.exchange_id}
onChange={(e) => handleInputChange('exchange_id', e.target.value)}
onChange={(e) =>
handleInputChange('exchange_id', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableExchanges.map(exchange => (
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.id).toUpperCase()}
{getShortName(
exchange.name || exchange.id
).toUpperCase()}
</option>
))}
</select>
@@ -287,7 +315,9 @@ export function TraderConfigModal({
{/* 第一行:保证金模式和初始余额 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<div className="flex gap-2">
<button
type="button"
@@ -302,7 +332,9 @@ export function TraderConfigModal({
</button>
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', false)}
onClick={() =>
handleInputChange('is_cross_margin', false)
}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
@@ -314,11 +346,18 @@ export function TraderConfigModal({
</div>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"> ($)</label>
<label className="text-sm text-[#EAECEF] block mb-2">
($)
</label>
<input
type="number"
value={formData.initial_balance}
onChange={(e) => handleInputChange('initial_balance', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'initial_balance',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="100"
@@ -329,17 +368,26 @@ export function TraderConfigModal({
{/* 第二行:AI 扫描决策间隔 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">{t('aiScanInterval', language)}</label>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiScanInterval', language)}
</label>
<input
type="number"
value={formData.scan_interval_minutes}
onChange={(e) => handleInputChange('scan_interval_minutes', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'scan_interval_minutes',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="60"
step="1"
/>
<p className="text-xs text-gray-500 mt-1">{t('scanIntervalRecommend', language)}</p>
<p className="text-xs text-gray-500 mt-1">
{t('scanIntervalRecommend', language)}
</p>
</div>
<div></div>
</div>
@@ -347,22 +395,36 @@ export function TraderConfigModal({
{/* 第三行:杠杆设置 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">BTC/ETH </label>
<label className="text-sm text-[#EAECEF] block mb-2">
BTC/ETH
</label>
<input
type="number"
value={formData.btc_eth_leverage}
onChange={(e) => handleInputChange('btc_eth_leverage', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'btc_eth_leverage',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="125"
/>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input
type="number"
value={formData.altcoin_leverage}
onChange={(e) => handleInputChange('altcoin_leverage', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'altcoin_leverage',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="75"
@@ -373,7 +435,9 @@ export function TraderConfigModal({
{/* 第三行:交易币种 */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]"> (使)</label>
<label className="text-sm text-[#EAECEF]">
(使)
</label>
<button
type="button"
onClick={() => setShowCoinSelector(!showCoinSelector)}
@@ -385,7 +449,9 @@ export function TraderConfigModal({
<input
type="text"
value={formData.trading_symbols}
onChange={(e) => handleInputChange('trading_symbols', e.target.value)}
onChange={(e) =>
handleInputChange('trading_symbols', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
/>
@@ -393,9 +459,11 @@ export function TraderConfigModal({
{/* 币种选择器 */}
{showCoinSelector && (
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
<div className="text-xs text-[#848E9C] mb-2"></div>
<div className="text-xs text-[#848E9C] mb-2">
</div>
<div className="flex flex-wrap gap-2">
{availableCoins.map(coin => (
{availableCoins.map((coin) => (
<button
key={coin}
type="button"
@@ -426,19 +494,27 @@ export function TraderConfigModal({
<input
type="checkbox"
checked={formData.use_coin_pool}
onChange={(e) => handleInputChange('use_coin_pool', e.target.checked)}
onChange={(e) =>
handleInputChange('use_coin_pool', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 Coin Pool </label>
<label className="text-sm text-[#EAECEF]">
使 Coin Pool
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.use_oi_top}
onChange={(e) => handleInputChange('use_oi_top', e.target.checked)}
onChange={(e) =>
handleInputChange('use_oi_top', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 OI Top </label>
<label className="text-sm text-[#EAECEF]">
使 OI Top
</label>
</div>
</div>
</div>
@@ -451,17 +527,24 @@ export function TraderConfigModal({
<div className="space-y-4">
{/* 系统提示词模板选择 */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select
value={formData.system_prompt_template}
onChange={(e) => handleInputChange('system_prompt_template', e.target.value)}
onChange={(e) =>
handleInputChange('system_prompt_template', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{promptTemplates.map(template => (
{promptTemplates.map((template) => (
<option key={template.name} value={template.name}>
{template.name === 'default' ? 'Default (默认稳健)' :
template.name === 'aggressive' ? 'Aggressive (激进)' :
template.name.charAt(0).toUpperCase() + template.name.slice(1)}
{template.name === 'default'
? 'Default (默认稳健)'
: template.name === 'aggressive'
? 'Aggressive (激进)'
: template.name.charAt(0).toUpperCase() +
template.name.slice(1)}
</option>
))}
</select>
@@ -474,21 +557,47 @@ export function TraderConfigModal({
<input
type="checkbox"
checked={formData.override_base_prompt}
onChange={(e) => handleInputChange('override_base_prompt', e.target.checked)}
onChange={(e) =>
handleInputChange('override_base_prompt', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]"></label>
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg> </span>
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>{' '}
</span>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'}
{formData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</label>
<textarea
value={formData.custom_prompt}
onChange={(e) => handleInputChange('custom_prompt', e.target.value)}
onChange={(e) =>
handleInputChange('custom_prompt', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
placeholder={formData.override_base_prompt ? "输入完整的交易策略提示词..." : "输入额外的交易策略提示..."}
placeholder={
formData.override_base_prompt
? '输入完整的交易策略提示词...'
: '输入额外的交易策略提示...'
}
/>
</div>
</div>
@@ -506,14 +615,19 @@ export function TraderConfigModal({
{onSave && (
<button
onClick={handleSave}
disabled={isSaving || !formData.trader_name || !formData.ai_model || !formData.exchange_id}
disabled={
isSaving ||
!formData.trader_name ||
!formData.ai_model ||
!formData.exchange_id
}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? '保存中...' : (isEditMode ? '保存修改' : '创建交易员')}
{isSaving ? '保存中...' : isEditMode ? '保存修改' : '创建交易员'}
</button>
)}
</div>
</div>
</div>
);
)
}
+108 -48
View File
@@ -1,57 +1,70 @@
import { useState } from 'react';
import type { TraderConfigData } from '../types';
import { useState } from 'react'
import type { TraderConfigData } from '../types'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
interface TraderConfigViewModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
isOpen: boolean
onClose: () => void
traderData?: TraderConfigData | null
}
export function TraderConfigViewModal({
isOpen,
onClose,
traderData
traderData,
}: TraderConfigViewModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null)
if (!isOpen || !traderData) return null;
if (!isOpen || !traderData) return null
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
await navigator.clipboard.writeText(text)
setCopiedField(fieldName)
setTimeout(() => setCopiedField(null), 2000)
} catch (error) {
console.error('Failed to copy:', error);
console.error('Failed to copy:', error)
}
};
}
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
const CopyButton = ({
text,
fieldName,
}: {
text: string
fieldName: string
}) => (
<button
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
style={{
background: copiedField === fieldName ? 'rgba(14, 203, 129, 0.1)' : 'rgba(240, 185, 11, 0.1)',
background:
copiedField === fieldName
? 'rgba(14, 203, 129, 0.1)'
: 'rgba(240, 185, 11, 0.1)',
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`,
}}
>
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
</button>
);
)
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: {
label: string;
value: string | number | boolean;
copyable?: boolean;
fieldName?: string;
const InfoRow = ({
label,
value,
copyable = false,
fieldName = '',
}: {
label: string
value: string | number | boolean
copyable?: boolean
fieldName?: string
}) => (
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
@@ -64,7 +77,7 @@ export function TraderConfigViewModal({
)}
</div>
</div>
);
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
@@ -79,9 +92,7 @@ export function TraderConfigViewModal({
<span className="text-lg">👁</span>
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
</h2>
<h2 className="text-xl font-bold text-[#EAECEF]"></h2>
<p className="text-sm text-[#848E9C] mt-1">
{traderData.trader_name}
</p>
@@ -91,9 +102,10 @@ export function TraderConfigViewModal({
{/* Running Status */}
<div
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
style={traderData.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
style={
traderData.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
<span>{traderData.is_running ? '●' : '○'}</span>
@@ -116,11 +128,30 @@ export function TraderConfigViewModal({
🤖
</h3>
<div className="space-y-3">
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" />
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" />
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} />
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} />
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} />
<InfoRow
label="交易员ID"
value={traderData.trader_id || ''}
copyable
fieldName="trader_id"
/>
<InfoRow
label="交易员名称"
value={traderData.trader_name}
copyable
fieldName="trader_name"
/>
<InfoRow
label="AI模型"
value={getShortName(traderData.ai_model).toUpperCase()}
/>
<InfoRow
label="交易所"
value={getShortName(traderData.exchange_id).toUpperCase()}
/>
<InfoRow
label="初始余额"
value={`$${traderData.initial_balance.toLocaleString()}`}
/>
</div>
</div>
@@ -130,9 +161,18 @@ export function TraderConfigViewModal({
</h3>
<div className="space-y-3">
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} />
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} />
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} />
<InfoRow
label="保证金模式"
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
/>
<InfoRow
label="BTC/ETH 杠杆"
value={`${traderData.btc_eth_leverage}x`}
/>
<InfoRow
label="山寨币杠杆"
value={`${traderData.altcoin_leverage}x`}
/>
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
@@ -148,7 +188,10 @@ export function TraderConfigViewModal({
📡
</h3>
<div className="space-y-3">
<InfoRow label="Coin Pool 信号" value={traderData.use_coin_pool} />
<InfoRow
label="Coin Pool 信号"
value={traderData.use_coin_pool}
/>
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
</div>
</div>
@@ -160,29 +203,41 @@ export function TraderConfigViewModal({
💬
</h3>
{traderData.custom_prompt && (
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" />
<CopyButton
text={traderData.custom_prompt}
fieldName="custom_prompt"
/>
)}
</div>
<div className="space-y-3">
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} />
<InfoRow
label="覆盖默认提示词"
value={traderData.override_base_prompt}
/>
{traderData.custom_prompt ? (
<div>
<div className="text-sm text-[#848E9C] mb-2">
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'}
{traderData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</div>
<div
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
whiteSpace: 'pre-wrap'
whiteSpace: 'pre-wrap',
}}
>
{traderData.custom_prompt}
</div>
</div>
) : (
<div className="text-sm text-[#848E9C] italic p-3 rounded border" style={{ border: '1px solid #2B3139' }}>
<div
className="text-sm text-[#848E9C] italic p-3 rounded border"
style={{ border: '1px solid #2B3139' }}
>
使
</div>
)}
@@ -199,7 +254,12 @@ export function TraderConfigViewModal({
</button>
<button
onClick={() => copyToClipboard(JSON.stringify(traderData, null, 2), 'full_config')}
onClick={() =>
copyToClipboard(
JSON.stringify(traderData, null, 2),
'full_config'
)
}
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
>
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
@@ -207,5 +267,5 @@ export function TraderConfigViewModal({
</div>
</div>
</div>
);
)
}
+8 -2
View File
@@ -21,7 +21,10 @@ export default function Typewriter({
const charIndexRef = useRef(0)
const timerRef = useRef<number | null>(null)
const blinkRef = useRef<number | null>(null)
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
const sanitizedLines = useMemo(
() => lines.map((l) => String(l ?? '')),
[lines]
)
useEffect(() => {
// 重置状态
@@ -69,7 +72,10 @@ export default function Typewriter({
}
}, [sanitizedLines, typingSpeed, lineDelay])
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
const displayText = useMemo(
() => typedLines.join('\n').replace(/undefined/g, ''),
[typedLines]
)
return (
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
+24 -21
View File
@@ -10,18 +10,18 @@ interface AboutSectionProps {
export default function AboutSection({ language }: AboutSectionProps) {
return (
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
<AnimatedSection id="about" backgroundColor="var(--brand-dark-gray)">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<motion.div
className='space-y-6'
className="space-y-6"
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full'
className="inline-flex items-center gap-2 px-4 py-2 rounded-full"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
@@ -29,11 +29,11 @@ export default function AboutSection({ language }: AboutSectionProps) {
whileHover={{ scale: 1.05 }}
>
<Target
className='w-4 h-4'
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className='text-sm font-semibold'
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{t('aboutNofx', language)}
@@ -41,45 +41,49 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div>
<h2
className='text-4xl font-bold'
className="text-4xl font-bold"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('whatIsNofx', language)}
</h2>
<p
className='text-lg leading-relaxed'
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{t('nofxNotAnotherBot', language)} {t('nofxDescription1', language)} {t('nofxDescription2', language)}
{t('nofxNotAnotherBot', language)}{' '}
{t('nofxDescription1', language)}{' '}
{t('nofxDescription2', language)}
</p>
<p
className='text-lg leading-relaxed'
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{t('nofxDescription3', language)} {t('nofxDescription4', language)} {t('nofxDescription5', language)}
{t('nofxDescription3', language)}{' '}
{t('nofxDescription4', language)}{' '}
{t('nofxDescription5', language)}
</p>
<motion.div
className='flex items-center gap-3 pt-4'
className="flex items-center gap-3 pt-4"
whileHover={{ x: 5 }}
>
<div
className='w-12 h-12 rounded-full flex items-center justify-center'
className="w-12 h-12 rounded-full flex items-center justify-center"
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
>
<Shield
className='w-6 h-6'
className="w-6 h-6"
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
<div>
<div
className='font-semibold'
className="font-semibold"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('youFullControl', language)}
</div>
<div
className='text-sm'
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
>
{t('fullControlDesc', language)}
@@ -88,9 +92,9 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div>
</motion.div>
<div className='relative'>
<div className="relative">
<div
className='rounded-2xl p-8'
className="rounded-2xl p-8"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
@@ -108,7 +112,7 @@ export default function AboutSection({ language }: AboutSectionProps) {
]}
typingSpeed={70}
lineDelay={900}
className='text-sm font-mono'
className="text-sm font-mono"
style={{
color: '#00FF88',
textShadow: '0 0 8px rgba(0,255,136,0.4)',
@@ -121,4 +125,3 @@ export default function AboutSection({ language }: AboutSectionProps) {
</AnimatedSection>
)
}
@@ -17,7 +17,7 @@ export default function AnimatedSection({
<motion.section
id={id}
ref={ref}
className='py-20 px-4'
className="py-20 px-4"
style={{ background: backgroundColor }}
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
@@ -27,4 +27,3 @@ export default function AnimatedSection({
</motion.section>
)
}
+28 -17
View File
@@ -2,31 +2,40 @@ import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection'
interface CardProps {
quote: string;
authorName: string;
handle: string;
avatarUrl: string;
tweetUrl: string;
delay: number;
quote: string
authorName: string
handle: string
avatarUrl: string
tweetUrl: string
delay: number
}
function TestimonialCard({ quote, authorName, delay }: CardProps) {
return (
<motion.div
className='p-6 rounded-xl'
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.1)' }}
className="p-6 rounded-xl"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.1)',
}}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay }}
whileHover={{ scale: 1.05 }}
>
<p className='text-lg mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<p className="text-lg mb-4" style={{ color: 'var(--brand-light-gray)' }}>
"{quote}"
</p>
<div className='flex items-center gap-2'>
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full"
style={{ background: 'var(--binance-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-secondary)' }}
>
{authorName}
</span>
</div>
@@ -35,7 +44,9 @@ function TestimonialCard({ quote, authorName, delay }: CardProps) {
}
export default function CommunitySection() {
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
// 推特内容整合(保持原三列布局,超出自动换行)
const items: CardProps[] = [
@@ -74,12 +85,12 @@ export default function CommunitySection() {
return (
<AnimatedSection>
<div className='max-w-7xl mx-auto'>
<div className="max-w-7xl mx-auto">
<motion.div
className='grid md:grid-cols-3 gap-6'
className="grid md:grid-cols-3 gap-6"
variants={staggerContainer}
initial='initial'
whileInView='animate'
initial="initial"
whileInView="animate"
viewport={{ once: true }}
>
{items.map((item, idx) => (
+33 -17
View File
@@ -10,61 +10,78 @@ interface FeaturesSectionProps {
export default function FeaturesSection({ language }: FeaturesSectionProps) {
return (
<AnimatedSection id='features'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<AnimatedSection id="features">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05 }}
>
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
<Rocket
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{t('coreFeatures', language)}
</span>
</motion.div>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<h2
className="text-4xl font-bold mb-4"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('whyChooseNofx', language)}
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('openCommunityDriven', language)}
</p>
</motion.div>
<div className='grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto'>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
<CryptoFeatureCard
icon={<Code className='w-8 h-8' />}
icon={<Code className="w-8 h-8" />}
title={t('openSourceSelfHosted', language)}
description={t('openSourceDesc', language)}
features={[
t('openSourceFeatures1', language),
t('openSourceFeatures2', language),
t('openSourceFeatures3', language),
t('openSourceFeatures4', language)
t('openSourceFeatures4', language),
]}
delay={0}
/>
<CryptoFeatureCard
icon={<Cpu className='w-8 h-8' />}
icon={<Cpu className="w-8 h-8" />}
title={t('multiAgentCompetition', language)}
description={t('multiAgentDesc', language)}
features={[
t('multiAgentFeatures1', language),
t('multiAgentFeatures2', language),
t('multiAgentFeatures3', language),
t('multiAgentFeatures4', language)
t('multiAgentFeatures4', language),
]}
delay={0.1}
/>
<CryptoFeatureCard
icon={<Lock className='w-8 h-8' />}
icon={<Lock className="w-8 h-8" />}
title={t('secureReliableTrading', language)}
description={t('secureDesc', language)}
features={[
t('secureFeatures1', language),
t('secureFeatures2', language),
t('secureFeatures3', language),
t('secureFeatures4', language)
t('secureFeatures4', language),
]}
delay={0.2}
/>
@@ -73,4 +90,3 @@ export default function FeaturesSection({ language }: FeaturesSectionProps) {
</AnimatedSection>
)
}
+68 -57
View File
@@ -6,57 +6,62 @@ interface FooterSectionProps {
export default function FooterSection({ language }: FooterSectionProps) {
return (
<footer style={{ borderTop: '1px solid var(--panel-border)', background: 'var(--brand-dark-gray)' }}>
<div className='max-w-[1200px] mx-auto px-6 py-10'>
<footer
style={{
borderTop: '1px solid var(--panel-border)',
background: 'var(--brand-dark-gray)',
}}
>
<div className="max-w-[1200px] mx-auto px-6 py-10">
{/* Brand */}
<div className='flex items-center gap-3 mb-8'>
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
<div className="flex items-center gap-3 mb-8">
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<div>
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
NOFX
</div>
<div className='text-xs' style={{ color: '#848E9C' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('futureStandardAI', language)}
</div>
</div>
</div>
{/* Multi-link columns */}
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8'>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8">
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('links', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://t.me/nofx_dev_community'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://t.me/nofx_dev_community"
target="_blank"
rel="noopener noreferrer"
>
Telegram
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://x.com/nofx_ai'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://x.com/nofx_ai"
target="_blank"
rel="noopener noreferrer"
>
X (Twitter)
</a>
@@ -66,38 +71,38 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('resources', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/blob/main/README.md'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
>
{t('documentation', language)}
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/issues'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/issues"
target="_blank"
rel="noopener noreferrer"
>
Issues
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/pulls'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/pulls"
target="_blank"
rel="noopener noreferrer"
>
Pull Requests
</a>
@@ -107,50 +112,53 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('supporters', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://asterdex.com/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://asterdex.com/"
target="_blank"
rel="noopener noreferrer"
>
Aster DEX
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://www.binance.com/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://www.binance.com/"
target="_blank"
rel="noopener noreferrer"
>
Binance
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://hyperliquid.xyz/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://hyperliquid.xyz/"
target="_blank"
rel="noopener noreferrer"
>
Hyperliquid
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://amber.ac/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://amber.ac/"
target="_blank"
rel="noopener noreferrer"
>
Amber.ac <span className='opacity-70'>{t('strategicInvestment', language)}</span>
Amber.ac{' '}
<span className="opacity-70">
{t('strategicInvestment', language)}
</span>
</a>
</li>
</ul>
@@ -159,11 +167,14 @@ export default function FooterSection({ language }: FooterSectionProps) {
{/* Bottom note (kept subtle) */}
<div
className='pt-6 mt-8 text-center text-xs'
style={{ color: 'var(--text-tertiary)', borderTop: '1px solid var(--panel-border)' }}
className="pt-6 mt-8 text-center text-xs"
style={{
color: 'var(--text-tertiary)',
borderTop: '1px solid var(--panel-border)',
}}
>
<p>{t('footerTitle', language)}</p>
<p className='mt-1'>{t('footerWarning', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
</div>
</div>
</footer>
+398 -196
View File
@@ -16,7 +16,17 @@ interface HeaderBarProps {
onPageChange?: (page: string) => void
}
export default function HeaderBar({ isLoggedIn = false, isHomePage = false, currentPage, language = 'zh' as Language, onLanguageChange, user, onLogout, isAdminMode = false, onPageChange }: HeaderBarProps) {
export default function HeaderBar({
isLoggedIn = false,
isHomePage = false,
currentPage,
language = 'zh' as Language,
onLanguageChange,
user,
onLogout,
isAdminMode = false,
onPageChange,
}: HeaderBarProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
@@ -26,10 +36,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setLanguageDropdownOpen(false)
}
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node)) {
if (
userDropdownRef.current &&
!userDropdownRef.current.contains(event.target as Node)
) {
setUserDropdownOpen(false)
}
}
@@ -41,47 +57,62 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
}, [])
return (
<nav className='fixed top-0 w-full z-50 header-bar'>
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
<div className='flex items-center justify-between h-16'>
<nav className="fixed top-0 w-full z-50 header-bar">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<a href='/' className='flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer'>
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}>
<a
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
>
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<span
className="text-xl font-bold"
style={{ color: 'var(--brand-yellow)' }}
>
NOFX
</span>
<span className='text-sm hidden sm:block' style={{ color: 'var(--text-secondary)' }}>
<span
className="text-sm hidden sm:block"
style={{ color: 'var(--text-secondary)' }}
>
Agentic Trading OS
</span>
</a>
{/* Desktop Menu */}
<div className='hidden md:flex items-center justify-between flex-1 ml-8'>
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
{/* Left Side - Navigation Tabs */}
<div className='flex items-center gap-4'>
<div className="flex items-center gap-4">
{isLoggedIn ? (
// Main app navigation when logged in
<>
<button
onClick={() => {
console.log('实时 button clicked, onPageChange:', onPageChange);
onPageChange?.('competition');
console.log(
'实时 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('competition')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
@@ -91,7 +122,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -101,24 +132,30 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
<button
onClick={() => {
console.log('配置 button clicked, onPageChange:', onPageChange);
onPageChange?.('traders');
console.log(
'配置 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('traders')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
@@ -128,7 +165,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -138,24 +175,30 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
<button
onClick={() => {
console.log('看板 button clicked, onPageChange:', onPageChange);
onPageChange?.('trader');
console.log(
'看板 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('trader')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
@@ -165,7 +208,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -176,22 +219,25 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
) : (
// Landing page navigation when not logged in
<a
href='/competition'
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
href="/competition"
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
@@ -201,7 +247,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -212,60 +258,110 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</div>
{/* Right Side - Original Navigation Items and Login */}
<div className='flex items-center gap-6'>
<div className="flex items-center gap-6">
{/* Only show original navigation items on home page */}
{isHomePage && [
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) }
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
className='text-sm transition-colors relative group'
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
<span
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300'
style={{ background: 'var(--brand-yellow)' }}
/>
</a>
))}
{isHomePage &&
[
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) },
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={
item.key === 'GitHub' || item.key === 'community'
? '_blank'
: undefined
}
rel={
item.key === 'GitHub' || item.key === 'community'
? 'noopener noreferrer'
: undefined
}
className="text-sm transition-colors relative group"
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
<span
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
style={{ background: 'var(--brand-yellow)' }}
/>
</a>
))}
{/* User Info and Actions */}
{isLoggedIn && user ? (
<div className='flex items-center gap-3'>
<div className="flex items-center gap-3">
{/* User Info with Dropdown */}
<div className='relative' ref={userDropdownRef}>
<div className="relative" ref={userDropdownRef}>
<button
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--panel-bg)'}
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background =
'rgba(255, 255, 255, 0.05)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = 'var(--panel-bg)')
}
>
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{user.email[0].toUpperCase()}
</div>
<span className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</span>
<ChevronDown className='w-4 h-4' style={{ color: 'var(--brand-light-gray)' }} />
<span
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</span>
<ChevronDown
className="w-4 h-4"
style={{ color: 'var(--brand-light-gray)' }}
/>
</button>
{userDropdownOpen && (
<div className='absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
<div className='px-3 py-2 border-b' style={{ borderColor: 'var(--panel-border)' }}>
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
<div className='text-sm font-medium' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
<div
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid var(--panel-border)',
}}
>
<div
className="px-3 py-2 border-b"
style={{ borderColor: 'var(--panel-border)' }}
>
<div
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('loggedInAs', language)}
</div>
<div
className="text-sm font-medium"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</div>
</div>
{!isAdminMode && onLogout && (
<button
@@ -273,10 +369,13 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout()
setUserDropdownOpen(false)
}}
className='w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center'
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{t('exitLogin', language)}
{t('exitLogin', language)}
</button>
)}
</div>
@@ -285,43 +384,58 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</div>
) : (
/* Show login/register buttons when not logged in and not on login/register pages */
currentPage !== 'login' && currentPage !== 'register' && (
<div className='flex items-center gap-3'>
currentPage !== 'login' &&
currentPage !== 'register' && (
<div className="flex items-center gap-3">
<a
href='/login'
className='px-3 py-2 text-sm font-medium transition-colors rounded'
href="/login"
className="px-3 py-2 text-sm font-medium transition-colors rounded"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('signIn', language)}
{t('signIn', language)}
</a>
<a
href='/register'
className='px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('signUp', language)}
{t('signUp', language)}
</a>
</div>
)
)}
{/* Language Toggle - Always at the rightmost */}
<div className='relative' ref={dropdownRef}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
style={{ color: 'var(--brand-light-gray)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onMouseEnter={(e) =>
(e.currentTarget.style.background =
'rgba(255, 255, 255, 0.05)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = 'transparent')
}
>
<span className='text-lg'>
<span className="text-lg">
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
</span>
<ChevronDown className='w-4 h-4' />
<ChevronDown className="w-4 h-4" />
</button>
{languageDropdownOpen && (
<div className='absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
<div
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid var(--panel-border)',
}}
>
<button
onClick={() => {
onLanguageChange?.('zh')
@@ -332,11 +446,14 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
}`}
style={{
color: 'var(--brand-light-gray)',
background: language === 'zh' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
background:
language === 'zh'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
>
<span className='text-base'>🇨🇳</span>
<span className='text-sm'></span>
<span className="text-base">🇨🇳</span>
<span className="text-sm"></span>
</button>
<button
onClick={() => {
@@ -348,11 +465,14 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
}`}
style={{
color: 'var(--brand-light-gray)',
background: language === 'en' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
background:
language === 'en'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
>
<span className='text-base'>🇺🇸</span>
<span className='text-sm'>English</span>
<span className="text-base">🇺🇸</span>
<span className="text-sm">English</span>
</button>
</div>
)}
@@ -363,11 +483,15 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu Button */}
<motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className='md:hidden'
className="md:hidden"
style={{ color: 'var(--brand-light-gray)' }}
whileTap={{ scale: 0.9 }}
>
{mobileMenuOpen ? <X className='w-6 h-6' /> : <Menu className='w-6 h-6' />}
{mobileMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</motion.button>
</div>
</div>
@@ -375,28 +499,41 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu */}
<motion.div
initial={false}
animate={mobileMenuOpen ? { height: 'auto', opacity: 1 } : { height: 0, opacity: 0 }}
animate={
mobileMenuOpen
? { height: 'auto', opacity: 1 }
: { height: 0, opacity: 0 }
}
transition={{ duration: 0.3 }}
className='md:hidden overflow-hidden'
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }}
className="md:hidden overflow-hidden"
style={{
background: 'var(--brand-dark-gray)',
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
}}
>
<div className='px-4 py-4 space-y-3'>
<div className="px-4 py-4 space-y-3">
{/* New Navigation Tabs */}
{isLoggedIn ? (
<button
onClick={() => {
console.log('移动端 实时 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 实时 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('competition')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
@@ -405,7 +542,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -414,13 +551,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</button>
) : (
<a
href='/competition'
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
href="/competition"
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
>
{/* Background for selected state */}
@@ -429,7 +569,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -442,18 +582,24 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
<>
<button
onClick={() => {
console.log('移动端 配置 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 配置 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('traders')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
@@ -462,7 +608,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -471,18 +617,24 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</button>
<button
onClick={() => {
console.log('移动端 看板 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 看板 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('trader')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
@@ -491,7 +643,7 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
@@ -502,47 +654,63 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
)}
{/* Original Navigation Items - Only on home page */}
{isHomePage && [
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) }
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
className='block text-sm py-2'
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
</a>
))}
{isHomePage &&
[
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) },
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={
item.key === 'GitHub' || item.key === 'community'
? '_blank'
: undefined
}
rel={
item.key === 'GitHub' || item.key === 'community'
? 'noopener noreferrer'
: undefined
}
className="block text-sm py-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
</a>
))}
{/* Language Toggle */}
<div className='py-2'>
<div className='flex items-center gap-2 mb-2'>
<span className='text-xs' style={{ color: 'var(--brand-light-gray)' }}>{t('language', language)}:</span>
<div className="py-2">
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('language', language)}:
</span>
</div>
<div className='space-y-1'>
<div className="space-y-1">
<button
onClick={() => {
onLanguageChange?.('zh')
setMobileMenuOpen(false)
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
language === 'zh' ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
language === 'zh'
? 'bg-yellow-500 text-black'
: 'text-gray-400 hover:text-white'
}`}
>
<span className='text-lg'>🇨🇳</span>
<span className='text-sm'></span>
<span className="text-lg">🇨🇳</span>
<span className="text-sm"></span>
</button>
<button
onClick={() => {
@@ -550,25 +718,49 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
setMobileMenuOpen(false)
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
language === 'en' ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
language === 'en'
? 'bg-yellow-500 text-black'
: 'text-gray-400 hover:text-white'
}`}
>
<span className='text-lg'>🇺🇸</span>
<span className='text-sm'>English</span>
<span className="text-lg">🇺🇸</span>
<span className="text-sm">English</span>
</button>
</div>
</div>
{/* User info and logout for mobile when logged in */}
{isLoggedIn && user && (
<div className='mt-4 pt-4' style={{ borderTop: '1px solid var(--panel-border)' }}>
<div className='flex items-center gap-2 px-3 py-2 mb-2 rounded' style={{ background: 'var(--panel-bg)' }}>
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
<div
className="mt-4 pt-4"
style={{ borderTop: '1px solid var(--panel-border)' }}
>
<div
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
style={{ background: 'var(--panel-bg)' }}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{user.email[0].toUpperCase()}
</div>
<div>
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
<div className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
<div
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('loggedInAs', language)}
</div>
<div
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</div>
</div>
</div>
{!isAdminMode && onLogout && (
@@ -577,8 +769,11 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout()
setMobileMenuOpen(false)
}}
className='w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center'
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{t('exitLogin', language)}
</button>
@@ -587,29 +782,36 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
)}
{/* Show login/register buttons when not logged in and not on login/register pages */}
{!isLoggedIn && currentPage !== 'login' && currentPage !== 'register' && (
<div className='space-y-2 mt-2'>
<a
href='/login'
className='block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors'
style={{ color: 'var(--brand-light-gray)', border: '1px solid var(--brand-light-gray)' }}
onClick={() => setMobileMenuOpen(false)}
>
{t('signIn', language)}
</a>
<a
href='/register'
className='block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
</div>
)}
{!isLoggedIn &&
currentPage !== 'login' &&
currentPage !== 'register' && (
<div className="space-y-2 mt-2">
<a
href="/login"
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
style={{
color: 'var(--brand-light-gray)',
border: '1px solid var(--brand-light-gray)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signIn', language)}
</a>
<a
href="/register"
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
</div>
)}
</div>
</motion.div>
</nav>
)
}
+98 -44
View File
@@ -27,33 +27,57 @@ export default function HeroSection({ language }: HeroSectionProps) {
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] },
}
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
return (
<section className='relative pt-32 pb-20 px-4'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
<section className="relative pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
<motion.div className='space-y-6 relative z-10' style={{ opacity, scale }} initial='initial' animate='animate' variants={staggerContainer}>
<motion.div
className="space-y-6 relative z-10"
style={{ opacity, scale }}
initial="initial"
animate="animate"
variants={staggerContainer}
>
<motion.div variants={fadeInUp}>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{
scale: 1.05,
boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)',
}}
>
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
<Sparkles
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{isLoading ? (
t('githubStarsInDays', language)
) : language === 'zh' ? (
<>
{daysOld} {' '}
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
<span className="inline-block tabular-nums">
{(animatedStars / 1000).toFixed(1)}
</span>
K+ GitHub Stars
</>
) : (
<>
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
<span className="inline-block tabular-nums">
{(animatedStars / 1000).toFixed(1)}
</span>
K+ GitHub Stars in {daysOld} days
</>
)}
@@ -61,48 +85,79 @@ export default function HeroSection({ language }: HeroSectionProps) {
</motion.div>
</motion.div>
<h1 className='text-5xl lg:text-7xl font-bold leading-tight' style={{ color: 'var(--brand-light-gray)' }}>
<h1
className="text-5xl lg:text-7xl font-bold leading-tight"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('heroTitle1', language)}
<br />
<span style={{ color: 'var(--brand-yellow)' }}>{t('heroTitle2', language)}</span>
<span style={{ color: 'var(--brand-yellow)' }}>
{t('heroTitle2', language)}
</span>
</h1>
<motion.p className='text-xl leading-relaxed' style={{ color: 'var(--text-secondary)' }} variants={fadeInUp}>
<motion.p
className="text-xl leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
variants={fadeInUp}
>
{t('heroDescription', language)}
</motion.p>
<div className='flex items-center gap-3 flex-wrap'>
<motion.a href='https://github.com/tinkle-community/nofx' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<div className="flex items-center gap-3 flex-wrap">
<motion.a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Stars'
className='h-7'
src="https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Stars"
className="h-7"
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/network/members' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<motion.a
href="https://github.com/tinkle-community/nofx/network/members"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Forks'
className='h-7'
src="https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Forks"
className="h-7"
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/graphs/contributors' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<motion.a
href="https://github.com/tinkle-community/nofx/graphs/contributors"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Contributors'
className='h-7'
src="https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Contributors"
className="h-7"
/>
</motion.a>
</div>
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}>
{t('poweredBy', language)}
<motion.p
className="text-xs pt-4"
style={{ color: 'var(--text-tertiary)' }}
variants={fadeInUp}
>
{t('poweredBy', language)}
</motion.p>
</motion.div>
{/* Right Visual - Interactive Robot */}
<div
className='relative w-full cursor-pointer'
className="relative w-full cursor-pointer"
onMouseEnter={() => {
handControls.start({
y: [-8, 8, -8],
@@ -111,9 +166,9 @@ export default function HeroSection({ language }: HeroSectionProps) {
transition: {
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.5, 1]
}
ease: 'easeInOut',
times: [0, 0.5, 1],
},
})
}}
onMouseLeave={() => {
@@ -123,16 +178,16 @@ export default function HeroSection({ language }: HeroSectionProps) {
x: 0,
transition: {
duration: 0.6,
ease: "easeOut"
}
ease: 'easeOut',
},
})
}}
>
{/* Background Layer */}
<motion.img
src='/images/hand-bg.png'
alt='NOFX Platform Background'
className='w-full opacity-90'
src="/images/hand-bg.png"
alt="NOFX Platform Background"
className="w-full opacity-90"
style={{ opacity, scale }}
whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 300 }}
@@ -140,15 +195,15 @@ export default function HeroSection({ language }: HeroSectionProps) {
{/* Hand Layer - Animated */}
<motion.img
src='/images/hand.png'
alt='Robot Hand'
className='absolute top-0 left-0 w-full'
src="/images/hand.png"
alt="Robot Hand"
className="absolute top-0 left-0 w-full"
style={{ opacity }}
animate={handControls}
initial={{ y: 0, rotate: 0, x: 0 }}
whileHover={{
scale: 1.05,
transition: { type: 'spring', stiffness: 400 }
transition: { type: 'spring', stiffness: 400 },
}}
/>
</div>
@@ -157,4 +212,3 @@ export default function HeroSection({ language }: HeroSectionProps) {
</section>
)
}
@@ -4,20 +4,36 @@ import { t, Language } from '../../i18n/translations'
function StepCard({ number, title, description, delay }: any) {
return (
<motion.div className='flex gap-6 items-start' initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay }} whileHover={{ x: 10 }}>
<motion.div
className="flex gap-6 items-start"
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay }}
whileHover={{ x: 10 }}
>
<motion.div
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl'
style={{ background: 'var(--binance-yellow)', color: 'var(--brand-black)' }}
className="flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl"
style={{
background: 'var(--binance-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{ scale: 1.2, rotate: 360 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
>
{number}
</motion.div>
<div>
<h3 className='text-2xl font-semibold mb-2' style={{ color: 'var(--brand-light-gray)' }}>
<h3
className="text-2xl font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{title}
</h3>
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}>
<p
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
</div>
@@ -29,46 +45,91 @@ interface HowItWorksSectionProps {
language: Language
}
export default function HowItWorksSection({ language }: HowItWorksSectionProps) {
export default function HowItWorksSection({
language,
}: HowItWorksSectionProps) {
return (
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<AnimatedSection id="how-it-works" backgroundColor="var(--brand-dark-gray)">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<h2
className="text-4xl font-bold mb-4"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('howToStart', language)}
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('fourSimpleSteps', language)}
</p>
</motion.div>
<div className='space-y-8'>
<div className="space-y-8">
{[
{ number: 1, title: t('step1Title', language), description: t('step1Desc', language) },
{ number: 2, title: t('step2Title', language), description: t('step2Desc', language) },
{ number: 3, title: t('step3Title', language), description: t('step3Desc', language) },
{ number: 4, title: t('step4Title', language), description: t('step4Desc', language) },
{
number: 1,
title: t('step1Title', language),
description: t('step1Desc', language),
},
{
number: 2,
title: t('step2Title', language),
description: t('step2Desc', language),
},
{
number: 3,
title: t('step3Title', language),
description: t('step3Desc', language),
},
{
number: 4,
title: t('step4Title', language),
description: t('step4Desc', language),
},
].map((step, index) => (
<StepCard key={step.number} {...step} delay={index * 0.1} />
))}
</div>
<motion.div
className='mt-12 p-6 rounded-xl flex items-start gap-4'
style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}
className="mt-12 p-6 rounded-xl flex items-start gap-4"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className='w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0' style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}>
<svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><path d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z'/><line x1='12' x2='12' y1='9' y2='13'/><line x1='12' x2='12.01' y1='17' y2='17'/></svg>
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
</div>
<div>
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}>
<div className="font-semibold mb-2" style={{ color: '#F6465D' }}>
{t('importantRiskWarning', language)}
</div>
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('riskWarningText', language)}
</p>
</div>
+35 -14
View File
@@ -10,7 +10,7 @@ interface LoginModalProps {
export default function LoginModal({ onClose, language }: LoginModalProps) {
return (
<motion.div
className='fixed inset-0 z-50 flex items-center justify-center p-4'
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -18,32 +18,50 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
onClick={onClose}
>
<motion.div
className='relative max-w-md w-full rounded-2xl p-8'
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="relative max-w-md w-full rounded-2xl p-8"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
initial={{ scale: 0.9, y: 50 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 50 }}
onClick={(e) => e.stopPropagation()}
>
<motion.button onClick={onClose} className='absolute top-4 right-4' style={{ color: 'var(--text-secondary)' }} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<X className='w-6 h-6' />
<motion.button
onClick={onClose}
className="absolute top-4 right-4"
style={{ color: 'var(--text-secondary)' }}
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
>
<X className="w-6 h-6" />
</motion.button>
<h2 className='text-2xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }}>
<h2
className="text-2xl font-bold mb-6"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('accessNofxPlatform', language)}
</h2>
<p className='text-sm mb-6' style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
{t('loginRegisterPrompt', language)}
</p>
<div className='space-y-3'>
<div className="space-y-3">
<motion.button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{
scale: 1.05,
boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)',
}}
whileTap={{ scale: 0.95 }}
>
{t('signIn', language)}
@@ -54,8 +72,12 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-dark-gray)',
color: 'var(--brand-light-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }}
>
@@ -66,4 +88,3 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
</motion.div>
)
}
+90 -58
View File
@@ -1,62 +1,86 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getSystemConfig } from '../lib/config';
import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'
interface User {
id: string;
email: string;
id: string
email: string
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
register: (email: string, password: string, betaCode?: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
logout: () => void;
isLoading: boolean;
user: User | null
token: string | null
login: (
email: string,
password: string
) => Promise<{
success: boolean
message?: string
userID?: string
requiresOTP?: boolean
}>
register: (
email: string,
password: string,
betaCode?: string
) => Promise<{
success: boolean
message?: string
userID?: string
otpSecret?: string
qrCodeURL?: string
}>
verifyOTP: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
completeRegistration: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig()
.then(data => {
.then((data) => {
if (data.admin_mode) {
// 管理员模式下,模拟admin用户
setUser({ id: 'admin', email: 'admin@localhost' });
setToken('admin-mode');
setUser({ id: 'admin', email: 'admin@localhost' })
setToken('admin-mode')
} else {
// 非管理员模式,检查本地存储中是否有token
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
}
setIsLoading(false);
setIsLoading(false)
})
.catch(err => {
console.error('Failed to fetch system config:', err);
.catch((err) => {
console.error('Failed to fetch system config:', err)
// 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false);
});
}, []);
setIsLoading(false)
})
}, [])
const login = async (email: string, password: string) => {
try {
@@ -66,9 +90,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
if (data.requires_otp) {
@@ -77,23 +101,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
userID: data.user_id,
requiresOTP: true,
message: data.message,
};
}
}
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '登录失败,请重试' };
return { success: false, message: '登录失败,请重试' }
}
return { success: false, message: '未知错误' };
};
return { success: false, message: '未知错误' }
}
const register = async (email: string, password: string, betaCode?: string) => {
const register = async (
email: string,
password: string,
betaCode?: string
) => {
try {
const requestBody: { email: string; password: string; beta_code?: string } = { email, password };
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
if (betaCode) {
requestBody.beta_code = betaCode;
requestBody.beta_code = betaCode
}
const response = await fetch('/api/register', {
@@ -102,9 +134,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
return {
@@ -113,14 +145,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url,
message: data.message,
};
}
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册失败,请重试' };
return { success: false, message: '注册失败,请重试' }
}
};
}
const verifyOTP = async (userID: string, otpCode: string) => {
try {
@@ -189,11 +221,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
};
setUser(null)
setToken(null)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
return (
<AuthContext.Provider
@@ -210,13 +242,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
>
{children}
</AuthContext.Provider>
);
)
}
export function useAuth() {
const context = useContext(AuthContext);
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error('useAuth must be used within an AuthProvider')
}
return context;
return context
}
+20 -16
View File
@@ -1,37 +1,41 @@
import { createContext, useContext, useState, ReactNode } from 'react';
import type { Language } from '../i18n/translations';
import { createContext, useContext, useState, ReactNode } from 'react'
import type { Language } from '../i18n/translations'
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
language: Language
setLanguage: (lang: Language) => void
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const LanguageContext = createContext<LanguageContextType | undefined>(
undefined
)
export function LanguageProvider({ children }: { children: ReactNode }) {
// Initialize language from localStorage or default to English
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('language');
return (saved === 'en' || saved === 'zh') ? saved : 'en';
});
const saved = localStorage.getItem('language')
return saved === 'en' || saved === 'zh' ? saved : 'en'
})
// Save language to localStorage whenever it changes
const handleSetLanguage = (lang: Language) => {
setLanguage(lang);
localStorage.setItem('language', lang);
};
setLanguage(lang)
localStorage.setItem('language', lang)
}
return (
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage }}>
<LanguageContext.Provider
value={{ language, setLanguage: handleSetLanguage }}
>
{children}
</LanguageContext.Provider>
);
)
}
export function useLanguage() {
const context = useContext(LanguageContext);
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider');
throw new Error('useLanguage must be used within LanguageProvider')
}
return context;
return context
}
+4 -2
View File
@@ -22,7 +22,9 @@ export function useGitHubStats(owner: string, repo: string): GitHubStats {
useEffect(() => {
const fetchGitHubStats = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`)
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`
)
if (!response.ok) {
throw new Error('Failed to fetch GitHub stats')
@@ -46,7 +48,7 @@ export function useGitHubStats(owner: string, repo: string): GitHubStats {
})
} catch (error) {
console.error('Error fetching GitHub stats:', error)
setStats(prev => ({
setStats((prev) => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
+18 -18
View File
@@ -1,29 +1,29 @@
import { useEffect, useState } from 'react';
import { getSystemConfig, type SystemConfig } from '../lib/config';
import { useEffect, useState } from 'react'
import { getSystemConfig, type SystemConfig } from '../lib/config'
export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<SystemConfig | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true;
let mounted = true
getSystemConfig()
.then((data) => {
if (!mounted) return;
setConfig(data);
setLoading(false);
if (!mounted) return
setConfig(data)
setLoading(false)
})
.catch((err: Error) => {
if (!mounted) return;
console.error('Failed to fetch system config:', err);
setError(err.message);
setLoading(false);
});
if (!mounted) return
console.error('Failed to fetch system config:', err)
setError(err.message)
setLoading(false)
})
return () => {
mounted = false;
};
}, []);
mounted = false
}
}, [])
return { config, loading, error };
return { config, loading, error }
}
+130 -68
View File
@@ -1,4 +1,4 @@
export type Language = 'en' | 'zh';
export type Language = 'en' | 'zh'
export const translations = {
en: {
@@ -147,7 +147,8 @@ export const translations = {
createFirstTrader: 'Create your first AI trader to get started',
configureModelsFirst: 'Please configure AI models first',
configureExchangesFirst: 'Please configure exchanges first',
configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first',
configureModelsAndExchangesFirst:
'Please configure AI models and exchanges first',
modelNotConfigured: 'Selected model is not configured',
exchangeNotConfigured: 'Selected exchange is not configured',
confirmDeleteTrader: 'Are you sure you want to delete this trader?',
@@ -192,9 +193,12 @@ export const translations = {
enterSigner: 'Enter Signer Address',
enterSecretKey: 'Enter Secret Key',
enterPassphrase: 'Enter Passphrase (Required for OKX)',
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication',
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key',
testnetDescription: 'Enable to connect to exchange test environment for simulated trading',
hyperliquidPrivateKeyDesc:
'Hyperliquid uses private key for trading authentication',
hyperliquidWalletAddressDesc:
'Wallet address corresponding to the private key',
testnetDescription:
'Enable to connect to exchange test environment for simulated trading',
securityWarning: 'Security Warning',
saveConfiguration: 'Save Configuration',
@@ -202,20 +206,25 @@ export const translations = {
positionMode: 'Position Mode',
crossMarginMode: 'Cross Margin',
isolatedMarginMode: 'Isolated Margin',
crossMarginDescription: 'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation',
crossMarginDescription:
'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription:
'Isolated margin: Each position manages collateral independently, risk isolation',
leverageConfiguration: 'Leverage Configuration',
btcEthLeverage: 'BTC/ETH Leverage',
altcoinLeverage: 'Altcoin Leverage',
leverageRecommendation: 'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
leverageRecommendation:
'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
tradingSymbols: 'Trading Symbols',
tradingSymbolsPlaceholder: 'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
tradingSymbolsPlaceholder:
'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
selectSymbols: 'Select Symbols',
selectTradingSymbols: 'Select Trading Symbols',
selectedSymbolsCount: 'Selected {count} symbols',
clearSelection: 'Clear All',
confirmSelection: 'Confirm',
tradingSymbolsDescription: 'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
tradingSymbolsDescription:
'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
@@ -223,7 +232,8 @@ export const translations = {
// Loading & Error
loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data',
noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
noCompleteData:
'No complete trading data (needs to complete open → close cycle)',
// AI Traders Page - Additional
inUse: 'In Use',
@@ -231,31 +241,44 @@ export const translations = {
noExchangesConfigured: 'No configured exchanges',
signalSource: 'Signal Source',
signalSourceConfig: 'Signal Source Configuration',
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source',
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source',
coinPoolDescription:
'API endpoint for coin pool data, leave blank to disable this signal source',
oiTopDescription:
'API endpoint for open interest rankings, leave blank to disable this signal source',
information: 'Information',
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources',
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals',
signalSourceInfo1:
'• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo2:
'• When creating traders, you can choose whether to use these signal sources',
signalSourceInfo3:
'• Configured URLs will be used to fetch market data and trading signals',
editAIModel: 'Edit AI Model',
addAIModel: 'Add AI Model',
confirmDeleteModel: 'Are you sure you want to delete this AI model configuration?',
confirmDeleteModel:
'Are you sure you want to delete this AI model configuration?',
selectModel: 'Select AI Model',
pleaseSelectModel: 'Please select a model',
customBaseURL: 'Base URL (Optional)',
customBaseURLPlaceholder: 'Custom API base URL, e.g.: https://api.openai.com/v1',
customBaseURLPlaceholder:
'Custom API base URL, e.g.: https://api.openai.com/v1',
leaveBlankForDefault: 'Leave blank to use default API address',
modelConfigInfo1: '• API Key will be encrypted and stored, please ensure it is valid',
modelConfigInfo1:
'• API Key will be encrypted and stored, please ensure it is valid',
modelConfigInfo2: '• Base URL is used for custom API server address',
modelConfigInfo3: '• After deleting configuration, traders using this model will not work properly',
modelConfigInfo3:
'• After deleting configuration, traders using this model will not work properly',
saveConfig: 'Save Configuration',
editExchange: 'Edit Exchange',
addExchange: 'Add Exchange',
confirmDeleteExchange: 'Are you sure you want to delete this exchange configuration?',
confirmDeleteExchange:
'Are you sure you want to delete this exchange configuration?',
pleaseSelectExchange: 'Please select an exchange',
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade',
exchangeConfigWarning1:
'• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning2:
'• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3:
'• After deleting configuration, related traders will not be able to trade',
edit: 'Edit',
viewGuide: 'View Guide',
binanceSetupGuide: 'Binance Setup Guide',
@@ -265,7 +288,8 @@ export const translations = {
createTraderFailed: 'Failed to create trader',
getTraderConfigFailed: 'Failed to get trader configuration',
modelConfigNotExist: 'Model configuration does not exist or is not enabled',
exchangeConfigNotExist: 'Exchange configuration does not exist or is not enabled',
exchangeConfigNotExist:
'Exchange configuration does not exist or is not enabled',
updateTraderFailed: 'Failed to update trader',
deleteTraderFailed: 'Failed to delete trader',
operationFailed: 'Operation failed',
@@ -302,12 +326,15 @@ export const translations = {
enterOTPCode: 'Enter 6-digit OTP code',
verifyOTP: 'Verify OTP',
setupTwoFactor: 'Set up two-factor authentication',
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
setupTwoFactorDesc:
'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions:
'Scan this QR code with Google Authenticator or Authy',
otpSecret: 'Or enter this secret manually:',
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
authStep1Title: 'Step 1: Install Google Authenticator',
authStep1Desc: 'Download and install Google Authenticator from your app store',
authStep1Desc:
'Download and install Google Authenticator from your app store',
authStep2Title: 'Step 2: Add account',
authStep2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
authStep3Title: 'Step 3: Verify setup',
@@ -342,33 +369,40 @@ export const translations = {
githubStarsInDays: '2.5K+ GitHub Stars in 3 days',
heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.',
heroDescription: 'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
poweredBy: 'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
heroDescription:
'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
poweredBy:
'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
// Landing Page CTA
readyToDefine: 'Ready to define the future of AI trading?',
startWithCrypto: 'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
startWithCrypto:
'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
getStartedNow: 'Get Started Now',
viewSourceCode: 'View Source Code',
// Features Section
coreFeatures: 'Core Features',
whyChooseNofx: 'Why Choose NOFX?',
openCommunityDriven: 'Open source, transparent, community-driven AI trading OS',
openCommunityDriven:
'Open source, transparent, community-driven AI trading OS',
openSourceSelfHosted: '100% Open Source & Self-Hosted',
openSourceDesc: 'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
openSourceDesc:
'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
openSourceFeatures1: 'Fully open source code',
openSourceFeatures2: 'Self-hosting deployment support',
openSourceFeatures3: 'Custom AI prompts',
openSourceFeatures4: 'Multi-model support (DeepSeek, Qwen)',
multiAgentCompetition: 'Multi-Agent Intelligent Competition',
multiAgentDesc: 'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
multiAgentDesc:
'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
multiAgentFeatures1: 'Multiple AI agents running in parallel',
multiAgentFeatures2: 'Automatic strategy optimization',
multiAgentFeatures3: 'Sandbox security testing',
multiAgentFeatures4: 'Cross-market strategy porting',
secureReliableTrading: 'Secure and Reliable Trading',
secureDesc: 'Enterprise-grade security, complete control over your funds and trading strategies.',
secureDesc:
'Enterprise-grade security, complete control over your funds and trading strategies.',
secureFeatures1: 'Local private key management',
secureFeatures2: 'Fine-grained API permission control',
secureFeatures3: 'Real-time risk monitoring',
@@ -377,12 +411,18 @@ export const translations = {
// About Section
aboutNofx: 'About NOFX',
whatIsNofx: 'What is NOFX?',
nofxNotAnotherBot: "NOFX is not another trading bot, but the 'Linux' of AI trading —",
nofxDescription1: 'a transparent, trustworthy open source OS that provides a unified',
nofxDescription2: "'decision-risk-execution' layer, supporting all asset classes.",
nofxDescription3: 'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
nofxDescription4: 'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
nofxDescription5: 'flywheel (developers get point rewards for PR contributions).',
nofxNotAnotherBot:
"NOFX is not another trading bot, but the 'Linux' of AI trading —",
nofxDescription1:
'a transparent, trustworthy open source OS that provides a unified',
nofxDescription2:
"'decision-risk-execution' layer, supporting all asset classes.",
nofxDescription3:
'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
nofxDescription4:
'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
nofxDescription5:
'flywheel (developers get point rewards for PR contributions).',
youFullControl: 'You 100% Control',
fullControlDesc: 'Complete control over AI prompts and funds',
startupMessages1: 'Starting automated trading system...',
@@ -391,17 +431,23 @@ export const translations = {
// How It Works Section
howToStart: 'How to Get Started with NOFX',
fourSimpleSteps: 'Four simple steps to start your AI automated trading journey',
fourSimpleSteps:
'Four simple steps to start your AI automated trading journey',
step1Title: 'Clone GitHub Repository',
step1Desc: 'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
step1Desc:
'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
step2Title: 'Configure Environment',
step2Desc: 'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
step2Desc:
'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
step3Title: 'Deploy & Run',
step3Desc: 'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
step3Desc:
'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
step4Title: 'Optimize & Contribute',
step4Desc: 'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
step4Desc:
'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
importantRiskWarning: 'Important Risk Warning',
riskWarningText: 'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
riskWarningText:
'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
// Community Section (testimonials are kept as-is since they are quotes)
@@ -415,7 +461,8 @@ export const translations = {
// Login Modal
accessNofxPlatform: 'Access NOFX Platform',
loginRegisterPrompt: 'Please login or register to access the full AI trading platform',
loginRegisterPrompt:
'Please login or register to access the full AI trading platform',
registerNewAccount: 'Register New Account',
},
zh: {
@@ -626,13 +673,15 @@ export const translations = {
altcoinLeverage: '山寨币杠杆',
leverageRecommendation: '推荐:BTC/ETH 5-10倍,山寨币 3-5倍,控制风险',
tradingSymbols: '交易币种',
tradingSymbolsPlaceholder: '输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT',
tradingSymbolsPlaceholder:
'输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT',
selectSymbols: '选择币种',
selectTradingSymbols: '选择交易币种',
selectedSymbolsCount: '已选择 {count} 个币种',
clearSelection: '清空选择',
confirmSelection: '确认选择',
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT',
tradingSymbolsDescription:
'留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT',
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
invalidSymbolFormat: '无效的币种格式:{symbol},必须以USDT结尾',
@@ -651,7 +700,8 @@ export const translations = {
coinPoolDescription: '用于获取币种池数据的API地址,留空则不使用此信号源',
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
information: '说明',
signalSourceInfo1: '• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
signalSourceInfo1:
'• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
editAIModel: '编辑AI模型',
@@ -759,12 +809,14 @@ export const translations = {
githubStarsInDays: '3 天内 2.5K+ GitHub Stars',
heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.',
heroDescription: 'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
heroDescription:
'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
poweredBy: '由 Aster DEX 和 Binance 提供支持,Amber.ac 战略投资。',
// Landing Page CTA
readyToDefine: '准备好定义 AI 交易的未来吗?',
startWithCrypto: '从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
startWithCrypto:
'从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
getStartedNow: '立即开始',
viewSourceCode: '查看源码',
@@ -794,11 +846,13 @@ export const translations = {
// About Section
aboutNofx: '关于 NOFX',
whatIsNofx: '什么是 NOFX',
nofxNotAnotherBot: 'NOFX 不是另一个交易机器人,而是 AI 交易的 \'Linux\' ——',
nofxDescription1: '一个透明、可信任的开源 OS,提供统一的 \'决策-风险-执行\'',
nofxNotAnotherBot: "NOFX 不是另一个交易机器人,而是 AI 交易的 'Linux' ——",
nofxDescription1: "一个透明、可信任的开源 OS,提供统一的 '决策-风险-执行'",
nofxDescription2: '层,支持所有资产类别。',
nofxDescription3: '从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
nofxDescription4: '达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
nofxDescription3:
'从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
nofxDescription4:
'达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
nofxDescription5: '贡献获积分奖励)。',
youFullControl: '你 100% 掌控',
fullControlDesc: '完全掌控 AI 提示词和资金',
@@ -810,15 +864,19 @@ export const translations = {
howToStart: '如何开始使用 NOFX',
fourSimpleSteps: '四个简单步骤,开启 AI 自动交易之旅',
step1Title: '拉取 GitHub 仓库',
step1Desc: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
step1Desc:
'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
step2Title: '配置环境',
step2Desc: '前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
step2Desc:
'前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
step3Title: '部署与运行',
step3Desc: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
step3Desc:
'一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
step4Title: '优化与贡献',
step4Desc: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。',
importantRiskWarning: '重要风险提示',
riskWarningText: 'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
riskWarningText:
'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
// Community Section (testimonials are kept as-is since they are quotes)
@@ -834,18 +892,22 @@ export const translations = {
accessNofxPlatform: '访问 NOFX 平台',
loginRegisterPrompt: '请选择登录或注册以访问完整的 AI 交易平台',
registerNewAccount: '注册新账号',
}
};
},
}
export function t(key: string, lang: Language, params?: Record<string, string | number>): string {
let text = translations[lang][key as keyof typeof translations['en']] || key;
export function t(
key: string,
lang: Language,
params?: Record<string, string | number>
): string {
let text = translations[lang][key as keyof (typeof translations)['en']] || key
// Replace parameters like {count}, {gap}, etc.
if (params) {
Object.entries(params).forEach(([param, value]) => {
text = text.replace(`{${param}}`, String(value));
});
text = text.replace(`{${param}}`, String(value))
})
}
return text;
return text
}
+55 -32
View File
@@ -12,54 +12,62 @@ html {
:root {
/* Binance Brand Colors */
--brand-yellow: #F0B90B;
--brand-yellow: #f0b90b;
--brand-black: #000000;
--brand-dark-gray: #0A0A0A;
--brand-light-gray: #EAECEF;
--brand-almost-white: #FAFAFA;
--brand-white: #FFFFFF;
--brand-dark-gray: #0a0a0a;
--brand-light-gray: #eaecef;
--brand-almost-white: #fafafa;
--brand-white: #ffffff;
/* Binance Theme Colors */
--binance-yellow: #F0B90B;
--binance-yellow-dark: #C99400;
--binance-yellow-light: #FCD535;
--binance-yellow: #f0b90b;
--binance-yellow-dark: #c99400;
--binance-yellow-light: #fcd535;
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
--background: #000000; /* Binance body bg */
--header-bg: #000000; /* Binance header bg */
--header-bg: #000000; /* Binance header bg */
--background-elevated: #000000;
--foreground: #EAECEF;
--panel-bg: #0A0A0A;
--foreground: #eaecef;
--panel-bg: #0a0a0a;
--panel-bg-hover: #111111;
--panel-border: #1A1A1A;
--panel-border-hover: #2A2A2A;
--panel-border: #1a1a1a;
--panel-border-hover: #2a2a2a;
/* Binance Signature Colors */
--binance-green: #0ECB81;
--binance-green: #0ecb81;
--binance-green-bg: rgba(14, 203, 129, 0.1);
--binance-green-border: rgba(14, 203, 129, 0.2);
--binance-red: #F6465D;
--binance-red: #f6465d;
--binance-red-bg: rgba(246, 70, 93, 0.1);
--binance-red-border: rgba(246, 70, 93, 0.2);
/* UI Colors */
--text-primary: #EAECEF;
--text-secondary: #848E9C;
--text-tertiary: #5E6673;
--text-disabled: #474D57;
--text-primary: #eaecef;
--text-secondary: #848e9c;
--text-tertiary: #5e6673;
--text-disabled: #474d57;
/* Chart Colors */
--grid-stroke: #1A1A1A;
--axis-tick: #5E6673;
--ref-line: #474D57;
--grid-stroke: #1a1a1a;
--axis-tick: #5e6673;
--ref-line: #474d57;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
line-height: 1.6;
font-weight: 400;
color-scheme: dark;
@@ -69,7 +77,7 @@ html {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum";
font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums;
}
@@ -140,7 +148,8 @@ body {
}
@keyframes pulse-glow {
0%, 100% {
0%,
100% {
opacity: 1;
box-shadow: 0 0 8px currentColor;
}
@@ -160,7 +169,8 @@ body {
}
@keyframes pulse-scale {
0%, 100% {
0%,
100% {
transform: scale(1);
}
50% {
@@ -240,11 +250,19 @@ button:disabled {
/* Binance gradient backgrounds */
.binance-gradient {
background: linear-gradient(135deg, var(--binance-yellow) 0%, var(--binance-yellow-light) 100%);
background: linear-gradient(
135deg,
var(--binance-yellow) 0%,
var(--binance-yellow-light) 100%
);
}
.binance-gradient-subtle {
background: linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%);
background: linear-gradient(
135deg,
rgba(240, 185, 11, 0.15) 0%,
rgba(252, 213, 53, 0.05) 100%
);
border: 1px solid rgba(240, 185, 11, 0.2);
}
@@ -456,7 +474,12 @@ tr:hover {
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--binance-yellow), transparent);
background: linear-gradient(
90deg,
transparent,
var(--binance-yellow),
transparent
);
opacity: 0;
transition: opacity 0.2s ease;
}
+118 -104
View File
@@ -11,22 +11,22 @@ import type {
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types';
} from '../types'
const API_BASE = '/api';
const API_BASE = '/api'
// Helper function to get auth headers
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token');
const token = localStorage.getItem('auth_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
export const api = {
@@ -34,16 +34,16 @@ export const api = {
async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/my-traders`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取trader列表失败');
return res.json();
})
if (!res.ok) throw new Error('获取trader列表失败')
return res.json()
},
// 获取公开的交易员列表(无需认证)
async getPublicTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/traders`);
if (!res.ok) throw new Error('获取公开trader列表失败');
return res.json();
const res = await fetch(`${API_BASE}/traders`)
if (!res.ok) throw new Error('获取公开trader列表失败')
return res.json()
},
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
@@ -51,76 +51,82 @@ export const api = {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('创建交易员失败');
return res.json();
})
if (!res.ok) throw new Error('创建交易员失败')
return res.json()
},
async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('删除交易员失败');
})
if (!res.ok) throw new Error('删除交易员失败')
},
async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('启动交易员失败');
})
if (!res.ok) throw new Error('启动交易员失败')
},
async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('停止交易员失败');
})
if (!res.ok) throw new Error('停止交易员失败')
},
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
async updateTraderPrompt(
traderId: string,
customPrompt: string
): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ custom_prompt: customPrompt }),
});
if (!res.ok) throw new Error('更新自定义策略失败');
})
if (!res.ok) throw new Error('更新自定义策略失败')
},
async getTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易员配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取交易员配置失败')
return res.json()
},
async updateTrader(traderId: string, request: CreateTraderRequest): Promise<TraderInfo> {
async updateTrader(
traderId: string,
request: CreateTraderRequest
): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易员失败');
return res.json();
})
if (!res.ok) throw new Error('更新交易员失败')
return res.json()
},
// AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取模型配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取模型配置失败')
return res.json()
},
// 获取系统支持的AI模型列表(无需认证)
async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`);
if (!res.ok) throw new Error('获取支持的模型失败');
return res.json();
const res = await fetch(`${API_BASE}/supported-models`)
if (!res.ok) throw new Error('获取支持的模型失败')
return res.json()
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
@@ -128,123 +134,125 @@ export const api = {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新模型配置失败');
})
if (!res.ok) throw new Error('更新模型配置失败')
},
// 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易所配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取交易所配置失败')
return res.json()
},
// 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`);
if (!res.ok) throw new Error('获取支持的交易所失败');
return res.json();
const res = await fetch(`${API_BASE}/supported-exchanges`)
if (!res.ok) throw new Error('获取支持的交易所失败')
return res.json()
},
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
async updateExchangeConfigs(
request: UpdateExchangeConfigRequest
): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易所配置失败');
})
if (!res.ok) throw new Error('更新交易所配置失败')
},
// 获取系统状态(支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`;
: `${API_BASE}/status`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取系统状态失败');
return res.json();
})
if (!res.ok) throw new Error('获取系统状态失败')
return res.json()
},
// 获取账户信息(支持trader_id
async getAccount(traderId?: string): Promise<AccountInfo> {
const url = traderId
? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`;
: `${API_BASE}/account`
const res = await fetch(url, {
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache',
},
});
if (!res.ok) throw new Error('获取账户信息失败');
const data = await res.json();
console.log('Account data fetched:', data);
return data;
})
if (!res.ok) throw new Error('获取账户信息失败')
const data = await res.json()
console.log('Account data fetched:', data)
return data
},
// 获取持仓列表(支持trader_id
async getPositions(traderId?: string): Promise<Position[]> {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`;
: `${API_BASE}/positions`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取持仓列表失败');
return res.json();
})
if (!res.ok) throw new Error('获取持仓列表失败')
return res.json()
},
// 获取决策日志(支持trader_id
async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`;
: `${API_BASE}/decisions`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取决策日志失败');
return res.json();
})
if (!res.ok) throw new Error('获取决策日志失败')
return res.json()
},
// 获取最新决策(支持trader_id
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`;
: `${API_BASE}/decisions/latest`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取最新决策失败');
return res.json();
})
if (!res.ok) throw new Error('获取最新决策失败')
return res.json()
},
// 获取统计信息(支持trader_id
async getStatistics(traderId?: string): Promise<Statistics> {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`;
: `${API_BASE}/statistics`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取统计信息失败');
return res.json();
})
if (!res.ok) throw new Error('获取统计信息失败')
return res.json()
},
// 获取收益率历史数据(支持trader_id)
async getEquityHistory(traderId?: string): Promise<any[]> {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`;
: `${API_BASE}/equity-history`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取历史数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取历史数据失败')
return res.json()
},
// 批量获取多个交易员的历史数据(无需认证)
@@ -255,54 +263,60 @@ export const api = {
'Content-Type': 'application/json',
},
body: JSON.stringify({ trader_ids: traderIds }),
});
if (!res.ok) throw new Error('获取批量历史数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取批量历史数据失败')
return res.json()
},
// 获取前5名交易员数据(无需认证)
async getTopTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/top-traders`);
if (!res.ok) throw new Error('获取前5名交易员失败');
return res.json();
const res = await fetch(`${API_BASE}/top-traders`)
if (!res.ok) throw new Error('获取前5名交易员失败')
return res.json()
},
// 获取公开交易员配置(无需认证)
async getPublicTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/trader/${traderId}/config`);
if (!res.ok) throw new Error('获取公开交易员配置失败');
return res.json();
const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
if (!res.ok) throw new Error('获取公开交易员配置失败')
return res.json()
},
// 获取AI学习表现分析(支持trader_id
async getPerformance(traderId?: string): Promise<any> {
const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`;
: `${API_BASE}/performance`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取AI学习数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取AI学习数据失败')
return res.json()
},
// 获取竞赛数据(无需认证)
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`);
if (!res.ok) throw new Error('获取竞赛数据失败');
return res.json();
const res = await fetch(`${API_BASE}/competition`)
if (!res.ok) throw new Error('获取竞赛数据失败')
return res.json()
},
// 用户信号源配置接口
async getUserSignalSource(): Promise<{coin_pool_url: string, oi_top_url: string}> {
async getUserSignalSource(): Promise<{
coin_pool_url: string
oi_top_url: string
}> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取用户信号源配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取用户信号源配置失败')
return res.json()
},
async saveUserSignalSource(coinPoolUrl: string, oiTopUrl: string): Promise<void> {
async saveUserSignalSource(
coinPoolUrl: string,
oiTopUrl: string
): Promise<void> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
method: 'POST',
headers: getAuthHeaders(),
@@ -310,7 +324,7 @@ export const api = {
coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl,
}),
});
if (!res.ok) throw new Error('保存用户信号源配置失败');
})
if (!res.ok) throw new Error('保存用户信号源配置失败')
},
};
}
+10 -12
View File
@@ -1,28 +1,26 @@
export interface SystemConfig {
admin_mode: boolean;
beta_mode: boolean;
admin_mode: boolean
beta_mode: boolean
}
let configPromise: Promise<SystemConfig> | null = null;
let cachedConfig: SystemConfig | null = null;
let configPromise: Promise<SystemConfig> | null = null
let cachedConfig: SystemConfig | null = null
export function getSystemConfig(): Promise<SystemConfig> {
if (cachedConfig) {
return Promise.resolve(cachedConfig);
return Promise.resolve(cachedConfig)
}
if (configPromise) {
return configPromise;
return configPromise
}
configPromise = fetch('/api/config')
.then((res) => res.json())
.then((data: SystemConfig) => {
cachedConfig = data;
return data;
cachedConfig = data
return data
})
.finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
});
return configPromise;
})
return configPromise
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -6,5 +6,5 @@ import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
</React.StrictMode>
)
+14 -20
View File
@@ -20,7 +20,7 @@ export function LandingPage() {
const { language, setLanguage } = useLanguage()
const isLoggedIn = !!user
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn);
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn)
return (
<>
<HeaderBar
@@ -43,7 +43,7 @@ export function LandingPage() {
}}
/>
<div
className='min-h-screen px-4 sm:px-6 lg:px-8'
className="min-h-screen px-4 sm:px-6 lg:px-8"
style={{
background: 'var(--brand-black)',
color: 'var(--brand-light-gray)',
@@ -56,10 +56,10 @@ export function LandingPage() {
<CommunitySection />
{/* CTA */}
<AnimatedSection backgroundColor='var(--panel-bg)'>
<div className='max-w-4xl mx-auto text-center'>
<AnimatedSection backgroundColor="var(--panel-bg)">
<div className="max-w-4xl mx-auto text-center">
<motion.h2
className='text-5xl font-bold mb-6'
className="text-5xl font-bold mb-6"
style={{ color: 'var(--brand-light-gray)' }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -68,7 +68,7 @@ export function LandingPage() {
{t('readyToDefine', language)}
</motion.h2>
<motion.p
className='text-xl mb-12'
className="text-xl mb-12"
style={{ color: 'var(--text-secondary)' }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -77,16 +77,10 @@ export function LandingPage() {
>
{t('startWithCrypto', language)}
</motion.p>
<div className='flex flex-wrap justify-center gap-4'>
<div className="flex flex-wrap justify-center gap-4">
<motion.button
onClick={() => {
if (isLoggedIn) {
window.location.href = '/traders'
} else {
setShowLoginModal(true)
}
}}
className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg'
onClick={() => setShowLoginModal(true)}
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
@@ -99,14 +93,14 @@ export function LandingPage() {
animate={{ x: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<ArrowRight className='w-5 h-5' />
<ArrowRight className="w-5 h-5" />
</motion.div>
</motion.button>
<motion.a
href='https://github.com/tinkle-community/nofx/tree/dev'
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg'
href="https://github.com/tinkle-community/nofx/tree/dev"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
style={{
background: 'transparent',
color: 'var(--brand-light-gray)',
-7
View File
@@ -1,7 +0,0 @@
// This is a test file to verify pre-commit hook
const testFunction = () => {
console.log('This should be formatted');
return 'test';
};
export default testFunction;
+146 -146
View File
@@ -1,204 +1,204 @@
export interface SystemStatus {
trader_id: string;
trader_name: string;
ai_model: string;
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
trader_id: string
trader_name: string
ai_model: string
is_running: boolean
start_time: string
runtime_minutes: number
call_count: number
initial_balance: number
scan_interval: string
stop_until: string
last_reset_time: string
ai_provider: string
}
export interface AccountInfo {
total_equity: number;
wallet_balance: number;
unrealized_profit: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
initial_balance: number;
daily_pnl: number;
position_count: number;
margin_used: number;
margin_used_pct: number;
total_equity: number
wallet_balance: number
unrealized_profit: number
available_balance: number
total_pnl: number
total_pnl_pct: number
total_unrealized_pnl: number
initial_balance: number
daily_pnl: number
position_count: number
margin_used: number
margin_used_pct: number
}
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
symbol: string
side: string
entry_price: number
mark_price: number
quantity: number
leverage: number
unrealized_pnl: number
unrealized_pnl_pct: number
liquidation_price: number
margin_used: number
}
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error?: string;
action: string
symbol: string
quantity: number
leverage: number
price: number
order_id: number
timestamp: string
success: boolean
error?: string
}
export interface AccountSnapshot {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
total_balance: number
available_balance: number
total_unrealized_profit: number
position_count: number
margin_used_pct: number
}
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
input_prompt: string;
cot_trace: string;
decision_json: string;
account_state: AccountSnapshot;
positions: any[];
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message?: string;
timestamp: string
cycle_number: number
input_prompt: string
cot_trace: string
decision_json: string
account_state: AccountSnapshot
positions: any[]
candidate_coins: string[]
decisions: DecisionAction[]
execution_log: string[]
success: boolean
error_message?: string
}
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
total_cycles: number
successful_cycles: number
failed_cycles: number
total_open_positions: number
total_close_positions: number
}
// AI Trading相关类型
export interface TraderInfo {
trader_id: string;
trader_name: string;
ai_model: string;
exchange_id?: string;
is_running?: boolean;
custom_prompt?: string;
trader_id: string
trader_name: string
ai_model: string
exchange_id?: string
is_running?: boolean
custom_prompt?: string
}
export interface AIModel {
id: string;
name: string;
provider: string;
enabled: boolean;
apiKey?: string;
customApiUrl?: string;
customModelName?: string;
id: string
name: string
provider: string
enabled: boolean
apiKey?: string
customApiUrl?: string
customModelName?: string
}
export interface Exchange {
id: string;
name: string;
type: 'cex' | 'dex';
enabled: boolean;
apiKey?: string;
secretKey?: string;
testnet?: boolean;
id: string
name: string
type: 'cex' | 'dex'
enabled: boolean
apiKey?: string
secretKey?: string
testnet?: boolean
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string;
hyperliquidWalletAddr?: string
// Aster 特定字段
asterUser?: string;
asterSigner?: string;
asterPrivateKey?: string;
asterUser?: string
asterSigner?: string
asterPrivateKey?: string
}
export interface CreateTraderRequest {
name: string;
ai_model_id: string;
exchange_id: string;
initial_balance: number;
scan_interval_minutes?: number;
btc_eth_leverage?: number;
altcoin_leverage?: number;
trading_symbols?: string;
custom_prompt?: string;
override_base_prompt?: boolean;
system_prompt_template?: string;
is_cross_margin?: boolean;
use_coin_pool?: boolean;
use_oi_top?: boolean;
name: string
ai_model_id: string
exchange_id: string
initial_balance: number
scan_interval_minutes?: number
btc_eth_leverage?: number
altcoin_leverage?: number
trading_symbols?: string
custom_prompt?: string
override_base_prompt?: boolean
system_prompt_template?: string
is_cross_margin?: boolean
use_coin_pool?: boolean
use_oi_top?: boolean
}
export interface UpdateModelConfigRequest {
models: {
[key: string]: {
enabled: boolean;
api_key: string;
custom_api_url?: string;
custom_model_name?: string;
};
};
enabled: boolean
api_key: string
custom_api_url?: string
custom_model_name?: string
}
}
}
export interface UpdateExchangeConfigRequest {
exchanges: {
[key: string]: {
enabled: boolean;
api_key: string;
secret_key: string;
testnet?: boolean;
enabled: boolean
api_key: string
secret_key: string
testnet?: boolean
// Hyperliquid 特定字段
hyperliquid_wallet_addr?: string;
hyperliquid_wallet_addr?: string
// Aster 特定字段
aster_user?: string;
aster_signer?: string;
aster_private_key?: string;
};
};
aster_user?: string
aster_signer?: string
aster_private_key?: string
}
}
}
// Competition related types
export interface CompetitionTraderData {
trader_id: string;
trader_name: string;
ai_model: string;
exchange: string;
total_equity: number;
total_pnl: number;
total_pnl_pct: number;
position_count: number;
margin_used_pct: number;
is_running: boolean;
trader_id: string
trader_name: string
ai_model: string
exchange: string
total_equity: number
total_pnl: number
total_pnl_pct: number
position_count: number
margin_used_pct: number
is_running: boolean
}
export interface CompetitionData {
traders: CompetitionTraderData[];
count: number;
traders: CompetitionTraderData[]
count: number
}
// Trader Configuration Data for View Modal
export interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
scan_interval_minutes: number;
is_running: boolean;
trader_id?: string
trader_name: string
ai_model: string
exchange_id: string
btc_eth_leverage: number
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
is_cross_margin: boolean
use_coin_pool: boolean
use_oi_top: boolean
initial_balance: number
scan_interval_minutes: number
is_running: boolean
}
+68 -68
View File
@@ -1,93 +1,93 @@
// 系统状态
export interface SystemStatus {
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
is_running: boolean
start_time: string
runtime_minutes: number
call_count: number
initial_balance: number
scan_interval: string
stop_until: string
last_reset_time: string
ai_provider: string
}
// 账户信息
export interface AccountInfo {
total_equity: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
margin_used: number;
margin_used_pct: number;
position_count: number;
initial_balance: number;
daily_pnl: number;
total_equity: number
available_balance: number
total_pnl: number
total_pnl_pct: number
total_unrealized_pnl: number
margin_used: number
margin_used_pct: number
position_count: number
initial_balance: number
daily_pnl: number
}
// 持仓信息
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
symbol: string
side: string
entry_price: number
mark_price: number
quantity: number
leverage: number
unrealized_pnl: number
unrealized_pnl_pct: number
liquidation_price: number
margin_used: number
}
// 决策动作
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error: string;
action: string
symbol: string
quantity: number
leverage: number
price: number
order_id: number
timestamp: string
success: boolean
error: string
}
// 决策记录
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
input_prompt: string;
cot_trace: string;
decision_json: string;
timestamp: string
cycle_number: number
input_prompt: string
cot_trace: string
decision_json: string
account_state: {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
};
total_balance: number
available_balance: number
total_unrealized_profit: number
position_count: number
margin_used_pct: number
}
positions: Array<{
symbol: string;
side: string;
position_amt: number;
entry_price: number;
mark_price: number;
unrealized_profit: number;
leverage: number;
liquidation_price: number;
}>;
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message: string;
symbol: string
side: string
position_amt: number
entry_price: number
mark_price: number
unrealized_profit: number
leverage: number
liquidation_price: number
}>
candidate_coins: string[]
decisions: DecisionAction[]
execution_log: string[]
success: boolean
error_message: string
}
// 统计信息
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
total_cycles: number
successful_cycles: number
failed_cycles: number
total_open_positions: number
total_close_positions: number
}
+4 -4
View File
@@ -12,7 +12,7 @@ export const TRADER_COLORS = [
'#a78bfa', // violet-400
'#4ade80', // green-400
'#fb7185', // rose-400
];
]
/**
* trader的索引位置获取颜色
@@ -24,8 +24,8 @@ export function getTraderColor(
traders: Array<{ trader_id: string }>,
traderId: string
): string {
const traderIndex = traders.findIndex((t) => t.trader_id === traderId);
if (traderIndex === -1) return TRADER_COLORS[0]; // 默认返回第一个颜色
const traderIndex = traders.findIndex((t) => t.trader_id === traderId)
if (traderIndex === -1) return TRADER_COLORS[0] // 默认返回第一个颜色
// 如果超出颜色池大小,循环使用
return TRADER_COLORS[traderIndex % TRADER_COLORS.length];
return TRADER_COLORS[traderIndex % TRADER_COLORS.length]
}