Merge pull request #489 from 0xEmberZz/feature/eslint&prettier

Feature/eslint&prettier
This commit is contained in:
SkywalkerJi
2025-11-05 13:01:29 +09:00
committed by GitHub
48 changed files with 9936 additions and 3468 deletions

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ web/node_modules/
node_modules/ node_modules/
web/dist/ web/dist/
web/.vite/ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json

36
.husky/_/husky.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd web && npx lint-staged

1
web/.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npm test

22
web/.prettierignore Normal file
View File

@@ -0,0 +1,22 @@
# Dependencies
node_modules
# Build outputs
dist
build
*.tsbuildinfo
# Config files
pnpm-lock.yaml
package-lock.json
yarn.lock
# Logs
*.log
# Coverage
coverage
# IDE
.vscode
.idea

13
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"arrowParens": "always",
"bracketSpacing": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}

89
web/eslint.config.js Normal file
View File

@@ -0,0 +1,89 @@
import js from '@eslint/js'
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import prettier from 'eslint-plugin-prettier'
export default [
{
ignores: ['dist', 'node_modules', 'build', '*.config.js']
},
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
globals: {
window: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly'
}
},
plugins: {
'@typescript-eslint': tseslint,
'react': react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'prettier': prettier
},
rules: {
...tseslint.configs.recommended.rules,
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// Prettier integration
'prettier/prettier': 'error',
// React rules
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
// 该规则在 TS 项目中经常与 TS 的类型检查重复,关闭以避免误报
'no-undef': 'off',
// TypeScript rules
// 放宽以下规则以避免在不改变功能的情况下大面积改动代码
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// React Refresh
'react-refresh/only-export-components': 'off',
// General rules
'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: {
version: 'detect'
}
}
}
]

3716
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@@ -22,13 +27,34 @@
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^18.3.17", "@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.0.7" "vite": "^6.0.7"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,json}": [
"prettier --write"
]
} }
} }

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

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo } from 'react'
import { import {
LineChart, LineChart,
Line, Line,
@@ -9,129 +9,140 @@ import {
ResponsiveContainer, ResponsiveContainer,
ReferenceLine, ReferenceLine,
Legend, Legend,
} from 'recharts'; } from 'recharts'
import useSWR from 'swr'; import useSWR from 'swr'
import { api } from '../lib/api'; import { api } from '../lib/api'
import type { CompetitionTraderData } from '../types'; import type { CompetitionTraderData } from '../types'
import { getTraderColor } from '../utils/traderColors'; import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react'
interface ComparisonChartProps { interface ComparisonChartProps {
traders: CompetitionTraderData[]; traders: CompetitionTraderData[]
} }
export function ComparisonChart({ traders }: ComparisonChartProps) { export function ComparisonChart({ traders }: ComparisonChartProps) {
const { language } = useLanguage(); const { language } = useLanguage()
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据 // 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
// 生成唯一的key当traders变化时会触发重新请求 // 生成唯一的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( const { data: allTraderHistories, isLoading } = useSWR(
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null, traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
async () => { async () => {
// 使用批量API一次性获取所有trader的历史数据 // 使用批量API一次性获取所有trader的历史数据
const traderIds = traders.map(trader => trader.trader_id); const traderIds = traders.map((trader) => trader.trader_id)
const batchData = await api.getEquityHistoryBatch(traderIds); const batchData = await api.getEquityHistoryBatch(traderIds)
// 转换为原格式,保持与原有代码兼容 // 转换为原格式,保持与原有代码兼容
return traders.map(trader => { return traders.map((trader) => {
return batchData.histories[trader.trader_id] || []; return batchData.histories[trader.trader_id] || []
}); })
}, },
{ {
refreshInterval: 30000, // 30秒刷新对比图表数据更新频率较低 refreshInterval: 30000, // 30秒刷新对比图表数据更新频率较低
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 20000, dedupingInterval: 20000,
} }
); )
// 将数据转换为与原格式兼容的结构 // 将数据转换为与原格式兼容的结构
const traderHistories = useMemo(() => { const traderHistories = useMemo(() => {
if (!allTraderHistories) { if (!allTraderHistories) {
return traders.map(() => ({ data: undefined })); return traders.map(() => ({ data: undefined }))
} }
return allTraderHistories.map(data => ({ data })); return allTraderHistories.map((data) => ({ data }))
}, [allTraderHistories, traders.length]); }, [allTraderHistories, traders.length])
// 使用useMemo自动处理数据合并直接使用data对象作为依赖 // 使用useMemo自动处理数据合并直接使用data对象作为依赖
const combinedData = useMemo(() => { const combinedData = useMemo(() => {
// 等待所有数据加载完成 // 等待所有数据加载完成
const allLoaded = traderHistories.every((h) => h.data); const allLoaded = traderHistories.every((h) => h.data)
if (!allLoaded) return []; if (!allLoaded) return []
console.log(`[${new Date().toISOString()}] Recalculating chart data...`); console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
// 新方案:按时间戳分组,不再依赖 cycle_number因为后端会重置 // 新方案:按时间戳分组,不再依赖 cycle_number因为后端会重置
// 收集所有时间戳 // 收集所有时间戳
const timestampMap = new Map<string, { const timestampMap = new Map<
timestamp: string; string,
time: string; {
traders: Map<string, { pnl_pct: number; equity: number }>; timestamp: string
}>(); time: string
traders: Map<string, { pnl_pct: number; equity: number }>
}
>()
traderHistories.forEach((history, index) => { traderHistories.forEach((history, index) => {
const trader = traders[index]; const trader = traders[index]
if (!history.data) return; 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) => { history.data.forEach((point: any) => {
const ts = point.timestamp; const ts = point.timestamp
if (!timestampMap.has(ts)) { if (!timestampMap.has(ts)) {
const time = new Date(ts).toLocaleTimeString('zh-CN', { const time = new Date(ts).toLocaleTimeString('zh-CN', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); })
timestampMap.set(ts, { timestampMap.set(ts, {
timestamp: ts, timestamp: ts,
time, time,
traders: new Map() traders: new Map(),
}); })
} }
// 计算盈亏百分比从total_pnl和balance计算 // 计算盈亏百分比从total_pnl和balance计算
// 假设初始余额 = balance - total_pnl // 假设初始余额 = balance - total_pnl
const initialBalance = point.balance - point.total_pnl; const initialBalance = point.balance - point.total_pnl
const pnlPct = initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0; const pnlPct =
initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0
timestampMap.get(ts)!.traders.set(trader.trader_id, { timestampMap.get(ts)!.traders.set(trader.trader_id, {
pnl_pct: pnlPct, pnl_pct: pnlPct,
equity: point.total_equity equity: point.total_equity,
}); })
}); })
}); })
// 按时间戳排序,转换为数组 // 按时间戳排序,转换为数组
const combined = Array.from(timestampMap.entries()) const combined = Array.from(timestampMap.entries())
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime()) .sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
.map(([ts, data], index) => { .map(([ts, data], index) => {
const entry: any = { const entry: any = {
index: index + 1, // 使用序号代替cycle index: index + 1, // 使用序号代替cycle
time: data.time, time: data.time,
timestamp: ts timestamp: ts,
}; }
traders.forEach((trader) => { traders.forEach((trader) => {
const traderData = data.traders.get(trader.trader_id); const traderData = data.traders.get(trader.trader_id)
if (traderData) { if (traderData) {
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct; entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct
entry[`${trader.trader_id}_equity`] = traderData.equity; entry[`${trader.trader_id}_equity`] = traderData.equity
} }
}); })
return entry; return entry
}); })
if (combined.length > 0) { if (combined.length > 0) {
const lastPoint = combined[combined.length - 1]; const lastPoint = combined[combined.length - 1]
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`); console.log(
`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`
)
} }
return combined; return combined
}, [allTraderHistories, traders]); }, [allTraderHistories, traders])
if (isLoading) { if (isLoading) {
return ( return (
@@ -139,67 +150,69 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
<div className="spinner mx-auto mb-4"></div> <div className="spinner mx-auto mb-4"></div>
<div className="text-sm font-semibold">Loading comparison data...</div> <div className="text-sm font-semibold">Loading comparison data...</div>
</div> </div>
); )
} }
if (combinedData.length === 0) { if (combinedData.length === 0) {
return ( return (
<div className="text-center py-16" style={{ color: '#848E9C' }}> <div className="text-center py-16" style={{ color: '#848E9C' }}>
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" /> <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 className="text-sm">{t('dataWillAppear', language)}</div>
</div> </div>
); )
} }
// 限制显示数据点 // 限制显示数据点
const MAX_DISPLAY_POINTS = 2000; const MAX_DISPLAY_POINTS = 2000
const displayData = const displayData =
combinedData.length > MAX_DISPLAY_POINTS combinedData.length > MAX_DISPLAY_POINTS
? combinedData.slice(-MAX_DISPLAY_POINTS) ? combinedData.slice(-MAX_DISPLAY_POINTS)
: combinedData; : combinedData
// 计算Y轴范围 // 计算Y轴范围
const calculateYDomain = () => { const calculateYDomain = () => {
const allValues: number[] = []; const allValues: number[] = []
displayData.forEach((point) => { displayData.forEach((point) => {
traders.forEach((trader) => { traders.forEach((trader) => {
const value = point[`${trader.trader_id}_pnl_pct`]; const value = point[`${trader.trader_id}_pnl_pct`]
if (value !== undefined) { 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 minVal = Math.min(...allValues)
const maxVal = Math.max(...allValues); const maxVal = Math.max(...allValues)
const range = Math.max(Math.abs(maxVal), Math.abs(minVal)); const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
const padding = Math.max(range * 0.2, 1); // 至少留1%余量 const padding = Math.max(range * 0.2, 1) // 至少留1%余量
return [ return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
Math.floor(minVal - padding), }
Math.ceil(maxVal + padding)
];
};
// 使用统一的颜色分配逻辑与Leaderboard保持一致 // 使用统一的颜色分配逻辑与Leaderboard保持一致
const traderColor = (traderId: string) => getTraderColor(traders, traderId); const traderColor = (traderId: string) => getTraderColor(traders, traderId)
// 自定义Tooltip - Binance Style // 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => { const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload
return ( 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' }}> <div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{data.time} - #{data.index} {data.time} - #{data.index}
</div> </div>
{traders.map((trader) => { {traders.map((trader) => {
const pnlPct = data[`${trader.trader_id}_pnl_pct`]; const pnlPct = data[`${trader.trader_id}_pnl_pct`]
const equity = data[`${trader.trader_id}_equity`]; const equity = data[`${trader.trader_id}_equity`]
if (pnlPct === undefined) return null; if (pnlPct === undefined) return null
return ( return (
<div key={trader.trader_id} className="mb-1.5 last:mb-0"> <div key={trader.trader_id} className="mb-1.5 last:mb-0">
@@ -209,33 +222,51 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
> >
{trader.trader_name} {trader.trader_name}
</div> </div>
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}> <div
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}% className="text-sm mono font-bold"
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}> 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) ({equity?.toFixed(2)} USDT)
</span> </span>
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
); )
} }
return null; return null
}; }
// 计算当前差距 // 计算当前差距
const currentGap = displayData.length > 0 ? (() => { const currentGap =
const lastPoint = displayData[displayData.length - 1]; displayData.length > 0
const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0); ? (() => {
return Math.abs(values[0] - values[1]); const lastPoint = displayData[displayData.length - 1]
})() : 0; const values = traders.map(
(t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
)
return Math.abs(values[0] - values[1])
})()
: 0
return ( return (
<div> <div>
<div style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}> <div
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */} {/* NOFX Watermark */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '20px', top: '20px',
@@ -245,116 +276,195 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
color: 'rgba(240, 185, 11, 0.15)', color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10, zIndex: 10,
pointerEvents: 'none', pointerEvents: 'none',
fontFamily: 'monospace' fontFamily: 'monospace',
}} }}
> >
NOFX NOFX
</div> </div>
<ResponsiveContainer width="100%" height={520}> <ResponsiveContainer width="100%" height={520}>
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}> <LineChart
<defs> data={displayData}
{traders.map((trader) => ( margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
<linearGradient >
key={`gradient-${trader.trader_id}`} <defs>
id={`gradient-${trader.trader_id}`} {traders.map((trader) => (
x1="0" <linearGradient
y1="0" key={`gradient-${trader.trader_id}`}
x2="0" id={`gradient-${trader.trader_id}`}
y2="1" x1="0"
> y1="0"
<stop offset="5%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.9} /> x2="0"
<stop offset="95%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.2} /> y2="1"
</linearGradient> >
))} <stop
</defs> 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 <XAxis
dataKey="time" dataKey="time"
stroke="#5E6673" stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }} tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }} tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)} interval={Math.floor(displayData.length / 12)}
angle={-15} angle={-15}
textAnchor="end" textAnchor="end"
height={60} 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
/> />
))}
<Legend <YAxis
wrapperStyle={{ paddingTop: '20px' }} stroke="#5E6673"
iconType="line" tick={{ fill: '#848E9C', fontSize: 12 }}
formatter={(value, entry: any) => { tickLine={{ stroke: '#2B3139' }}
const traderId = traders.find((t) => value === t.trader_name)?.trader_id; domain={calculateYDomain()}
const trader = traders.find((t) => t.trader_id === traderId); tickFormatter={(value) => `${value.toFixed(1)}%`}
return ( width={60}
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}> />
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span> <Tooltip content={<CustomTooltip />} />
);
}} <ReferenceLine
/> y={0}
</LineChart> stroke="#474D57"
</ResponsiveContainer> 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> </div>
{/* Stats */} {/* 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
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}> className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5"
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</div> style={{ borderTop: '1px solid #2B3139' }}
<div className="text-sm md:text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</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('comparisonMode', language)}
</div>
<div
className="text-sm md:text-base font-bold"
style={{ color: '#EAECEF' }}
>
PnL %
</div>
</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
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div> className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: combinedData.length})}</div> 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>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}> <div
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</div> className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
<div className="text-sm md:text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}> 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)}% {currentGap.toFixed(2)}%
</div> </div>
</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
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div> className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}> 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 {combinedData.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}` ? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)} : t('allData', language)}
@@ -362,5 +472,5 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,18 +1,18 @@
import { useState } from 'react'; import { useState } from 'react'
import { Trophy, Medal } from 'lucide-react'; import { Trophy, Medal } from 'lucide-react'
import useSWR from 'swr'; import useSWR from 'swr'
import { api } from '../lib/api'; import { api } from '../lib/api'
import type { CompetitionData } from '../types'; import type { CompetitionData } from '../types'
import { ComparisonChart } from './ComparisonChart'; import { ComparisonChart } from './ComparisonChart'
import { TraderConfigViewModal } from './TraderConfigViewModal'; import { TraderConfigViewModal } from './TraderConfigViewModal'
import { getTraderColor } from '../utils/traderColors'; import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
export function CompetitionPage() { export function CompetitionPage() {
const { language } = useLanguage(); const { language } = useLanguage()
const [selectedTrader, setSelectedTrader] = useState<any>(null); const [selectedTrader, setSelectedTrader] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false)
const { data: competition } = useSWR<CompetitionData>( const { data: competition } = useSWR<CompetitionData>(
'competition', 'competition',
@@ -22,24 +22,24 @@ export function CompetitionPage() {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, dedupingInterval: 10000,
} }
); )
const handleTraderClick = async (traderId: string) => { const handleTraderClick = async (traderId: string) => {
try { try {
const traderConfig = await api.getTraderConfig(traderId); const traderConfig = await api.getTraderConfig(traderId)
setSelectedTrader(traderConfig); setSelectedTrader(traderConfig)
setIsModalOpen(true); setIsModalOpen(true)
} catch (error) { } catch (error) {
console.error('Failed to fetch trader config:', error); console.error('Failed to fetch trader config:', error)
// 对于未登录用户,不显示详细配置,这是正常行为 // 对于未登录用户,不显示详细配置,这是正常行为
// 竞赛页面主要用于查看排行榜和基本信息 // 竞赛页面主要用于查看排行榜和基本信息
} }
}; }
const closeModal = () => { const closeModal = () => {
setIsModalOpen(false); setIsModalOpen(false)
setSelectedTrader(null); setSelectedTrader(null)
}; }
if (!competition) { if (!competition) {
return ( return (
@@ -61,7 +61,7 @@ export function CompetitionPage() {
</div> </div>
</div> </div>
</div> </div>
); )
} }
// 如果有数据返回但没有交易员,显示空状态 // 如果有数据返回但没有交易员,显示空状态
@@ -71,16 +71,31 @@ export function CompetitionPage() {
{/* Competition Header - 精简版 */} {/* 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 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="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={{ <div
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)' style={{
}}> background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} /> 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>
<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)} {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)} 0 {t('traders', language)}
</span> </span>
</h1> </h1>
@@ -93,7 +108,10 @@ export function CompetitionPage() {
{/* Empty State */} {/* Empty State */}
<div className="binance-card p-8 text-center"> <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' }}> <h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
{t('noTraders', language)} {t('noTraders', language)}
</h3> </h3>
@@ -102,32 +120,47 @@ export function CompetitionPage() {
</p> </p>
</div> </div>
</div> </div>
); )
} }
// 按收益率排序 // 按收益率排序
const sortedTraders = [...competition.traders].sort( const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct (a, b) => b.total_pnl_pct - a.total_pnl_pct
); )
// 找出领先者 // 找出领先者
const leader = sortedTraders[0]; const leader = sortedTraders[0]
return ( return (
<div className="space-y-5 animate-fade-in"> <div className="space-y-5 animate-fade-in">
{/* Competition Header - 精简版 */} {/* 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 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="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={{ <div
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)', className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)' style={{
}}> background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} /> 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>
<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)} {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)} {competition.count} {t('traders', language)}
</span> </span>
</h1> </h1>
@@ -137,10 +170,23 @@ export function CompetitionPage() {
</div> </div>
</div> </div>
<div className="text-left md:text-right w-full md:w-auto"> <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-xs mb-1" style={{ color: '#848E9C' }}>
<div className="text-base md:text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div> {t('leader', language)}
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}> </div>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}% <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> </div>
</div> </div>
@@ -148,9 +194,15 @@ export function CompetitionPage() {
{/* Left/Right Split: Performance Chart + Leaderboard */} {/* Left/Right Split: Performance Chart + Leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Left: Performance Comparison Chart */} {/* 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"> <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)} {t('performanceComparison', language)}
</h2> </h2>
<div className="text-xs" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
@@ -161,19 +213,35 @@ export function CompetitionPage() {
</div> </div>
{/* Right: Leaderboard */} {/* 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"> <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)} {t('leaderboard', language)}
</h2> </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)} {t('live', language)}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{sortedTraders.map((trader, index) => { {sortedTraders.map((trader, index) => {
const isLeader = index === 0; const isLeader = index === 0
const traderColor = getTraderColor(sortedTraders, trader.trader_id); const traderColor = getTraderColor(
sortedTraders,
trader.trader_id
)
return ( return (
<div <div
@@ -181,21 +249,44 @@ export function CompetitionPage() {
onClick={() => handleTraderClick(trader.trader_id)} onClick={() => handleTraderClick(trader.trader_id)}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg" className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
style={{ 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'}`, 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"> <div className="flex items-center justify-between">
{/* Rank & Name */} {/* Rank & Name */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-6 flex items-center justify-center"> <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> <div>
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div> <div
<div className="text-xs mono font-semibold" style={{ color: traderColor }}> className="font-bold text-sm"
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()} 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> </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"> <div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* Total Equity */} {/* Total Equity */}
<div className="text-right"> <div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('equity', language)}</div> <div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}> {t('equity', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.total_equity?.toFixed(2) || '0.00'} {trader.total_equity?.toFixed(2) || '0.00'}
</div> </div>
</div> </div>
{/* P&L */} {/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]"> <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 <div
className="text-base md:text-lg font-bold mono" 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 ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}% {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div> </div>
<div className="text-xs mono" style={{ color: '#848E9C' }}> <div
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'} className="text-xs mono"
style={{ color: '#848E9C' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl?.toFixed(2) || '0.00'}
</div> </div>
</div> </div>
{/* Positions */} {/* Positions */}
<div className="text-right"> <div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pos', language)}</div> <div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}> {t('pos', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.position_count} {trader.position_count}
</div> </div>
<div className="text-xs" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
@@ -240,9 +352,16 @@ export function CompetitionPage() {
<div> <div>
<div <div
className="px-2 py-1 rounded text-xs font-bold" className="px-2 py-1 rounded text-xs font-bold"
style={trader.is_running style={
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' } trader.is_running
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' } ? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
} }
> >
{trader.is_running ? '●' : '○'} {trader.is_running ? '●' : '○'}
@@ -251,7 +370,7 @@ export function CompetitionPage() {
</div> </div>
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
@@ -259,56 +378,81 @@ export function CompetitionPage() {
{/* Head-to-Head Stats */} {/* Head-to-Head Stats */}
{competition.traders.length === 2 && ( {competition.traders.length === 2 && (
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}> <div
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}> 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)} {t('headToHead', language)}
</h2> </h2>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{sortedTraders.map((trader, index) => { {sortedTraders.map((trader, index) => {
const isWinning = index === 0; const isWinning = index === 0
const opponent = sortedTraders[1 - index]; const opponent = sortedTraders[1 - index]
const gap = trader.total_pnl_pct - opponent.total_pnl_pct; const gap = trader.total_pnl_pct - opponent.total_pnl_pct
return ( return (
<div <div
key={trader.trader_id} key={trader.trader_id}
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]" className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
style={isWinning 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)', background:
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)' '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)' background: '#0B0E11',
} border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
}
} }
> >
<div className="text-center"> <div className="text-center">
<div <div
className="text-sm md:text-base font-bold mb-2" 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} {trader.trader_name}
</div> </div>
<div className="text-lg md:text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}> <div
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}% 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> </div>
{isWinning && gap > 0 && ( {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) })} {t('leadingBy', language, { gap: gap.toFixed(2) })}
</div> </div>
)} )}
{!isWinning && gap < 0 && ( {!isWinning && gap < 0 && (
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}> <div
{t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })} className="text-xs font-semibold"
style={{ color: '#F6465D' }}
>
{t('behindBy', language, {
gap: Math.abs(gap).toFixed(2),
})}
</div> </div>
)} )}
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
@@ -321,5 +465,5 @@ export function CompetitionPage() {
traderData={selectedTrader} traderData={selectedTrader}
/> />
</div> </div>
); )
} }

View File

@@ -1,115 +1,136 @@
import * as React from "react"; import * as React from 'react'
import { motion } from "framer-motion"; import { motion } from 'framer-motion'
import { Check } from "lucide-react"; import { Check } from 'lucide-react'
import { cn } from "../lib/utils"; import { cn } from '../lib/utils'
interface CryptoFeatureCardProps { interface CryptoFeatureCardProps {
icon: React.ReactNode; icon: React.ReactNode
title: string; title: string
description: string; description: string
features: string[]; features: string[]
className?: string; className?: string
delay?: number; delay?: number
} }
export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureCardProps>( export const CryptoFeatureCard = React.forwardRef<
({ icon, title, description, features, className, delay = 0 }, ref) => { HTMLDivElement,
const [isHovered, setIsHovered] = React.useState(false); CryptoFeatureCardProps
>(({ icon, title, description, features, className, delay = 0 }, ref) => {
const [isHovered, setIsHovered] = React.useState(false)
return ( return (
<motion.div <motion.div
ref={ref} ref={ref}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay }} transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)} onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)} onHoverEnd={() => setIsHovered(false)}
className="relative h-full" 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 {/* Animated glow border effect */}
className={cn( <motion.div
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl", className="absolute inset-0 opacity-0 pointer-events-none"
"bg-gradient-to-br from-[#000000] to-[#0A0A0A]", animate={{
"border-[#1A1A1A] hover:border-[#F0B90B]/50", opacity: isHovered ? 1 : 0,
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]", }}
className 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 <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={{ 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 }} 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> </motion.div>
{/* Background pattern */} {/* Title */}
<div className="absolute inset-0 opacity-5"> <h3
<div className="text-2xl font-bold mb-3"
className="absolute inset-0" style={{ color: 'var(--brand-light-gray)' }}
style={{ >
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`, {title}
backgroundSize: "32px 32px", </h3>
}}
/>
</div>
<div className="relative z-10 p-8 flex flex-col h-full"> {/* Description */}
{/* Icon container */} <p
<motion.div className="mb-6 flex-grow leading-relaxed"
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl" style={{ color: 'var(--text-secondary)' }}
style={{ >
background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)', {description}
border: '1px solid rgba(240, 185, 11, 0.3)' </p>
}}
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>
{/* Title */} {/* Features list */}
<h3 className="text-2xl font-bold mb-3" style={{ color: 'var(--brand-light-gray)' }}>{title}</h3> <div className="space-y-3 mb-6">
{features.map((feature, index) => (
{/* Description */} <motion.div
<p className="mb-6 flex-grow leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{description}</p> key={index}
initial={{ opacity: 0, x: -10 }}
{/* Features list */} whileInView={{ opacity: 1, x: 0 }}
<div className="space-y-3 mb-6"> viewport={{ once: true }}
{features.map((feature, index) => ( transition={{ delay: delay + index * 0.1 }}
<motion.div className="flex items-start gap-3"
key={index} >
initial={{ opacity: 0, x: -10 }} <div className="mt-0.5 flex-shrink-0">
whileInView={{ opacity: 1, x: 0 }} <div
viewport={{ once: true }} className="w-5 h-5 rounded-full flex items-center justify-center"
transition={{ delay: delay + index * 0.1 }} style={{ background: 'rgba(240, 185, 11, 0.2)' }}
className="flex items-start gap-3" >
> <Check
<div className="mt-0.5 flex-shrink-0"> className="w-3 h-3"
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.2)' }}> style={{ color: 'var(--brand-yellow)' }}
<Check className="w-3 h-3" style={{ color: 'var(--brand-yellow)' }} /> />
</div>
</div> </div>
<span className="text-sm" style={{ color: 'var(--brand-light-gray)' }}>{feature}</span> </div>
</motion.div> <span
))} className="text-sm"
</div> style={{ color: 'var(--brand-light-gray)' }}
>
{feature}
</span>
</motion.div>
))}
</div> </div>
</div> </div>
</motion.div> </div>
); </motion.div>
} )
); })
CryptoFeatureCard.displayName = "CryptoFeatureCard"; CryptoFeatureCard.displayName = 'CryptoFeatureCard'

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState } from 'react'
import { import {
LineChart, LineChart,
Line, Line,
@@ -8,28 +8,35 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
ReferenceLine, ReferenceLine,
} from 'recharts'; } from 'recharts'
import useSWR from 'swr'; import useSWR from 'swr'
import { api } from '../lib/api'; import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown } from 'lucide-react' import {
AlertTriangle,
BarChart3,
DollarSign,
Percent,
TrendingUp as ArrowUp,
TrendingDown as ArrowDown,
} from 'lucide-react'
interface EquityPoint { interface EquityPoint {
timestamp: string; timestamp: string
total_equity: number; total_equity: number
pnl: number; pnl: number
pnl_pct: number; pnl_pct: number
cycle_number: number; cycle_number: number
} }
interface EquityChartProps { interface EquityChartProps {
traderId?: string; traderId?: string
} }
export function EquityChart({ traderId }: EquityChartProps) { export function EquityChart({ traderId }: EquityChartProps) {
const { language } = useLanguage(); const { language } = useLanguage()
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar'); const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR<EquityPoint[]>( const { data: history, error } = useSWR<EquityPoint[]>(
traderId ? `equity-history-${traderId}` : 'equity-history', traderId ? `equity-history-${traderId}` : 'equity-history',
@@ -39,7 +46,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 20000, dedupingInterval: 20000,
} }
); )
const { data: account } = useSWR( const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account', traderId ? `account-${traderId}` : 'account',
@@ -49,24 +56,24 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, dedupingInterval: 10000,
} }
); )
if (error) { if (error) {
return ( return (
<div className='binance-card p-6'> <div className="binance-card p-6">
<div <div
className='flex items-center gap-3 p-4 rounded' className="flex items-center gap-3 p-4 rounded"
style={{ style={{
background: 'rgba(246, 70, 93, 0.1)', background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.2)', 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>
<div className='font-semibold' style={{ color: '#F6465D' }}> <div className="font-semibold" style={{ color: '#F6465D' }}>
{t('loadingError', language)} {t('loadingError', language)}
</div> </div>
<div className='text-sm' style={{ color: '#848E9C' }}> <div className="text-sm" style={{ color: '#848E9C' }}>
{error.message} {error.message}
</div> </div>
</div> </div>
@@ -76,22 +83,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
} }
// 过滤掉无效数据total_equity为0或小于1的数据点API失败导致 // 过滤掉无效数据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) { if (!validHistory || validHistory.length === 0) {
return ( return (
<div className='binance-card p-6'> <div className="binance-card p-6">
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}> <h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
{t('accountEquityCurve', language)} {t('accountEquityCurve', language)}
</h3> </h3>
<div className='text-center py-16' style={{ color: '#848E9C' }}> <div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className='mb-4 flex justify-center opacity-50'> <div className="mb-4 flex justify-center opacity-50">
<BarChart3 className='w-16 h-16' /> <BarChart3 className="w-16 h-16" />
</div> </div>
<div className='text-lg font-semibold mb-2'> <div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)} {t('noHistoricalData', language)}
</div> </div>
<div className='text-sm'>{t('dataWillAppear', language)}</div> <div className="text-sm">{t('dataWillAppear', language)}</div>
</div> </div>
</div> </div>
) )
@@ -99,20 +106,20 @@ export function EquityChart({ traderId }: EquityChartProps) {
// 限制显示最近的数据点(性能优化) // 限制显示最近的数据点(性能优化)
// 如果数据超过2000个点只显示最近2000个 // 如果数据超过2000个点只显示最近2000个
const MAX_DISPLAY_POINTS = 2000; const MAX_DISPLAY_POINTS = 2000
const displayHistory = validHistory.length > MAX_DISPLAY_POINTS const displayHistory =
? validHistory.slice(-MAX_DISPLAY_POINTS) validHistory.length > MAX_DISPLAY_POINTS
: validHistory; ? validHistory.slice(-MAX_DISPLAY_POINTS)
: validHistory
// 计算初始余额使用第一个有效数据点如果无数据则从account获取最后才用默认值 // 计算初始余额使用第一个有效数据点如果无数据则从account获取最后才用默认值
const initialBalance = validHistory[0]?.total_equity const initialBalance =
|| account?.total_equity validHistory[0]?.total_equity || account?.total_equity || 100 // 默认值改为100与常见配置一致
|| 100; // 默认值改为100与常见配置一致
// 转换数据格式 // 转换数据格式
const chartData = displayHistory.map((point) => { const chartData = displayHistory.map((point) => {
const pnl = point.total_equity - initialBalance; const pnl = point.total_equity - initialBalance
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2); const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
return { return {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', { time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit', hour: '2-digit',
@@ -123,43 +130,45 @@ export function EquityChart({ traderId }: EquityChartProps) {
raw_equity: point.total_equity, raw_equity: point.total_equity,
raw_pnl: pnl, raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct), raw_pnl_pct: parseFloat(pnlPct),
}; }
}); })
const currentValue = chartData[chartData.length - 1]; const currentValue = chartData[chartData.length - 1]
const isProfit = currentValue.raw_pnl >= 0; const isProfit = currentValue.raw_pnl >= 0
// 计算Y轴范围 // 计算Y轴范围
const calculateYDomain = () => { const calculateYDomain = () => {
if (displayMode === 'percent') { if (displayMode === 'percent') {
// 百分比模式找到最大最小值留20%余量 // 百分比模式找到最大最小值留20%余量
const values = chartData.map(d => d.value); const values = chartData.map((d) => d.value)
const minVal = Math.min(...values); const minVal = Math.min(...values)
const maxVal = Math.max(...values); const maxVal = Math.max(...values)
const range = Math.max(Math.abs(maxVal), Math.abs(minVal)); const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
const padding = Math.max(range * 0.2, 1); // 至少留1%余量 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)]
} else { } else {
// 美元模式以初始余额为基准上下留10%余量 // 美元模式以初始余额为基准上下留10%余量
const values = chartData.map(d => d.value); const values = chartData.map((d) => d.value)
const minVal = Math.min(...values, initialBalance); const minVal = Math.min(...values, initialBalance)
const maxVal = Math.max(...values, initialBalance); const maxVal = Math.max(...values, initialBalance)
const range = maxVal - minVal; const range = maxVal - minVal
const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量 const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
return [ return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
} }
}; }
// 自定义Tooltip - Binance Style // 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => { const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload
return ( return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}> <div
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</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' }}> <div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT {data.raw_equity.toFixed(2)} USDT
</div> </div>
@@ -172,38 +181,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%) {data.raw_pnl_pct}%)
</div> </div>
</div> </div>
); )
} }
return null; return null
}; }
return ( 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 */} {/* Header */}
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'> <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-1">
<h3 <h3
className='text-base sm:text-lg font-bold mb-2' className="text-base sm:text-lg font-bold mb-2"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{t('accountEquityCurve', language)} {t('accountEquityCurve', language)}
</h3> </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 <span
className='text-2xl sm:text-3xl font-bold mono' className="text-2xl sm:text-3xl font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{account?.total_equity.toFixed(2) || '0.00'} {account?.total_equity.toFixed(2) || '0.00'}
<span <span
className='text-base sm:text-lg ml-1' className="text-base sm:text-lg ml-1"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
USDT USDT
</span> </span>
</span> </span>
<div className='flex items-center gap-2 flex-wrap'> <div className="flex items-center gap-2 flex-wrap">
<span <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={{ style={{
color: isProfit ? '#0ECB81' : '#F6465D', color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit 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 ? '+' : ''} {isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}% {currentValue.raw_pnl_pct}%
</span> </span>
<span <span
className='text-xs sm:text-sm mono' className="text-xs sm:text-sm mono"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
({isProfit ? '+' : ''} ({isProfit ? '+' : ''}
@@ -233,12 +246,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Display Mode Toggle */} {/* Display Mode Toggle */}
<div <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' }} style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
> >
<button <button
onClick={() => setDisplayMode('dollar')} 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={ style={
displayMode === 'dollar' displayMode === 'dollar'
? { ? {
@@ -249,11 +262,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' } : { background: 'transparent', color: '#848E9C' }
} }
> >
<DollarSign className='w-4 h-4' /> USDT <DollarSign className="w-4 h-4" /> USDT
</button> </button>
<button <button
onClick={() => setDisplayMode('percent')} 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={ style={
displayMode === 'percent' displayMode === 'percent'
? { ? {
@@ -264,15 +277,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' } : { background: 'transparent', color: '#848E9C' }
} }
> >
<Percent className='w-4 h-4' /> <Percent className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
{/* Chart */} {/* 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 */} {/* NOFX Watermark */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '15px', top: '15px',
@@ -282,35 +302,35 @@ export function EquityChart({ traderId }: EquityChartProps) {
color: 'rgba(240, 185, 11, 0.15)', color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10, zIndex: 10,
pointerEvents: 'none', pointerEvents: 'none',
fontFamily: 'monospace' fontFamily: 'monospace',
}} }}
> >
NOFX NOFX
</div> </div>
<ResponsiveContainer width='100%' height={280}> <ResponsiveContainer width="100%" height={280}>
<LineChart <LineChart
data={chartData} data={chartData}
margin={{ top: 10, right: 20, left: 5, bottom: 30 }} margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
> >
<defs> <defs>
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'> <linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} /> <stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
<stop offset='95%' stopColor='#FCD535' stopOpacity={0.2} /> <stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' /> <CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis <XAxis
dataKey='time' dataKey="time"
stroke='#5E6673' stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }} tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }} tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)} interval={Math.floor(chartData.length / 10)}
angle={-15} angle={-15}
textAnchor='end' textAnchor="end"
height={60} height={60}
/> />
<YAxis <YAxis
stroke='#5E6673' stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }} tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }} tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()} domain={calculateYDomain()}
@@ -321,8 +341,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<ReferenceLine <ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0} y={displayMode === 'dollar' ? initialBalance : 0}
stroke='#474D57' stroke="#474D57"
strokeDasharray='3 3' strokeDasharray="3 3"
label={{ label={{
value: value:
displayMode === 'dollar' displayMode === 'dollar'
@@ -333,9 +353,9 @@ export function EquityChart({ traderId }: EquityChartProps) {
}} }}
/> />
<Line <Line
type='natural' type="natural"
dataKey='value' dataKey="value"
stroke='url(#colorGradient)' stroke="url(#colorGradient)"
strokeWidth={3} strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }} dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{ activeDot={{
@@ -352,72 +372,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Footer Stats */} {/* Footer Stats */}
<div <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' }} style={{ borderTop: '1px solid #2B3139' }}
> >
<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)' }} style={{ background: 'rgba(240, 185, 11, 0.05)' }}
> >
<div <div
className='text-xs mb-1 uppercase tracking-wider' className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
{t('initialBalance', language)} {t('initialBalance', language)}
</div> </div>
<div <div
className='text-xs sm:text-sm font-bold mono' className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{initialBalance.toFixed(2)} USDT {initialBalance.toFixed(2)} USDT
</div> </div>
</div> </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)' }} style={{ background: 'rgba(240, 185, 11, 0.05)' }}
> >
<div <div
className='text-xs mb-1 uppercase tracking-wider' className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
{t('currentEquity', language)} {t('currentEquity', language)}
</div> </div>
<div <div
className='text-xs sm:text-sm font-bold mono' className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{currentValue.raw_equity.toFixed(2)} USDT {currentValue.raw_equity.toFixed(2)} USDT
</div> </div>
</div> </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)' }} style={{ background: 'rgba(240, 185, 11, 0.05)' }}
> >
<div <div
className='text-xs mb-1 uppercase tracking-wider' className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
{t('historicalCycles', language)} {t('historicalCycles', language)}
</div> </div>
<div <div
className='text-xs sm:text-sm font-bold mono' className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{validHistory.length} {t('cycles', language)} {validHistory.length} {t('cycles', language)}
</div> </div>
</div> </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)' }} style={{ background: 'rgba(240, 185, 11, 0.05)' }}
> >
<div <div
className='text-xs mb-1 uppercase tracking-wider' className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}
> >
{t('displayRange', language)} {t('displayRange', language)}
</div> </div>
<div <div
className='text-xs sm:text-sm font-bold mono' className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{validHistory.length > MAX_DISPLAY_POINTS {validHistory.length > MAX_DISPLAY_POINTS

View File

@@ -1,107 +1,165 @@
import React from 'react'; import React from 'react'
interface IconProps { interface IconProps {
width?: number; width?: number
height?: number; height?: number
className?: string; className?: string
} }
// Binance SVG 图标组件 // Binance SVG 图标组件
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => ( const BinanceIcon: React.FC<IconProps> = ({
<svg width = 24,
xmlns="http://www.w3.org/2000/svg" height = 24,
width={width} className,
height={height} }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="-52.785 -88 457.47 528" viewBox="-52.785 -88 457.47 528"
className={className} className={className}
> >
<path <path
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z" d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
fill="#f0b90b" fill="#f0b90b"
/> />
</svg> </svg>
); )
// Hyperliquid SVG 图标组件 // Hyperliquid SVG 图标组件
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => ( const HyperliquidIcon: React.FC<IconProps> = ({
<svg width = 24,
width={width} height = 24,
height={height} className,
viewBox="0 0 144 144" }) => (
fill="none" <svg
width={width}
height={height}
viewBox="0 0 144 144"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
> >
<path <path
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z" d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
fill="#97FCE4" fill="#97FCE4"
/> />
</svg> </svg>
); )
// Aster SVG 图标组件 // Aster SVG 图标组件
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => ( const AsterIcon: React.FC<IconProps> = ({
<svg width = 24,
width={width} height = 24,
height={height} className,
viewBox="0 0 32 32" }) => (
fill="none" <svg
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
> >
<defs> <defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse"> <linearGradient
<stop stopColor="#F4D5B1"/> id="paint0_linear_428_3535"
<stop offset="1" stopColor="#FFD29F"/> 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>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse"> <linearGradient
<stop stopColor="#F4D5B1"/> id="paint1_linear_428_3535"
<stop offset="1" stopColor="#FFD29F"/> 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>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse"> <linearGradient
<stop stopColor="#F4D5B1"/> id="paint2_linear_428_3535"
<stop offset="1" stopColor="#FFD29F"/> 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>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse"> <linearGradient
<stop stopColor="#F4D5B1"/> id="paint3_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
</linearGradient> </linearGradient>
</defs> </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
<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)"/> 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"
<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)"/> fill="url(#paint0_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="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> </svg>
); )
// 获取交易所图标的函数 // 获取交易所图标的函数
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => { export const getExchangeIcon = (
exchangeType: string,
props: IconProps = {}
) => {
// 支持完整ID或类型名 // 支持完整ID或类型名
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' : const type = exchangeType.toLowerCase().includes('binance')
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' : ? 'binance'
exchangeType.toLowerCase().includes('aster') ? 'aster' : : exchangeType.toLowerCase().includes('hyperliquid')
exchangeType.toLowerCase(); ? 'hyperliquid'
: exchangeType.toLowerCase().includes('aster')
? 'aster'
: exchangeType.toLowerCase()
const iconProps = { const iconProps = {
width: props.width || 24, width: props.width || 24,
height: props.height || 24, height: props.height || 24,
className: props.className className: props.className,
}; }
switch (type) { switch (type) {
case 'binance': case 'binance':
case 'cex': case 'cex':
return <BinanceIcon {...iconProps} />; return <BinanceIcon {...iconProps} />
case 'hyperliquid': case 'hyperliquid':
case 'dex': case 'dex':
return <HyperliquidIcon {...iconProps} />; return <HyperliquidIcon {...iconProps} />
case 'aster': case 'aster':
return <AsterIcon {...iconProps} />; return <AsterIcon {...iconProps} />
default: default:
return ( return (
<div <div
className={props.className} className={props.className}
style={{ style={{
width: props.width || 24, width: props.width || 24,
height: props.height || 24, height: props.height || 24,
borderRadius: '50%', borderRadius: '50%',
background: '#2B3139', background: '#2B3139',
@@ -110,11 +168,11 @@ export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) =>
justifyContent: 'center', justifyContent: 'center',
fontSize: '12px', fontSize: '12px',
fontWeight: 'bold', fontWeight: 'bold',
color: '#EAECEF' color: '#EAECEF',
}} }}
> >
{type[0]?.toUpperCase() || '?'} {type[0]?.toUpperCase() || '?'}
</div> </div>
); )
} }
}; }

View File

@@ -1,12 +1,12 @@
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
interface HeaderProps { interface HeaderProps {
simple?: boolean; // For login/register pages simple?: boolean // For login/register pages
} }
export function Header({ simple = false }: HeaderProps) { export function Header({ simple = false }: HeaderProps) {
const { language, setLanguage } = useLanguage(); const { language, setLanguage } = useLanguage()
return ( return (
<header className="glass sticky top-0 z-50 backdrop-blur-xl"> <header className="glass sticky top-0 z-50 backdrop-blur-xl">
@@ -28,15 +28,19 @@ export function Header({ simple = false }: HeaderProps) {
)} )}
</div> </div>
</div> </div>
{/* Right - Language Toggle (always show) */} {/* 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 <button
onClick={() => setLanguage('zh')} onClick={() => setLanguage('zh')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all" className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh' style={
? { background: '#F0B90B', color: '#000' } language === 'zh'
: { background: 'transparent', color: '#848E9C' } ? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
} }
> >
@@ -44,9 +48,10 @@ export function Header({ simple = false }: HeaderProps) {
<button <button
onClick={() => setLanguage('en')} onClick={() => setLanguage('en')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all" className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en' style={
? { background: '#F0B90B', color: '#000' } language === 'en'
: { background: 'transparent', color: '#848E9C' } ? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
} }
> >
EN EN
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
</div> </div>
</div> </div>
</header> </header>
); )
} }

View File

@@ -1,208 +1,272 @@
import React, { useState } from 'react'; import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
import HeaderBar from './landing/HeaderBar'; import HeaderBar from './landing/HeaderBar'
export function LoginPage() { export function LoginPage() {
const { language } = useLanguage(); const { language } = useLanguage()
const { login, verifyOTP } = useAuth(); const { login, verifyOTP } = useAuth()
const [step, setStep] = useState<'login' | 'otp'>('login'); const [step, setStep] = useState<'login' | 'otp'>('login')
const [email, setEmail] = useState(''); const [email, setEmail] = useState('')
const [password, setPassword] = useState(''); const [password, setPassword] = useState('')
const [otpCode, setOtpCode] = useState(''); const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState(''); const [userID, setUserID] = useState('')
const [error, setError] = useState(''); const [error, setError] = useState('')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault()
setError(''); setError('')
setLoading(true); setLoading(true)
const result = await login(email, password)
const result = await login(email, password);
if (result.success) { if (result.success) {
if (result.requiresOTP && result.userID) { if (result.requiresOTP && result.userID) {
setUserID(result.userID); setUserID(result.userID)
setStep('otp'); setStep('otp')
} }
} else { } else {
setError(result.message || t('loginFailed', language)); setError(result.message || t('loginFailed', language))
} }
setLoading(false); setLoading(false)
}; }
const handleOTPVerify = async (e: React.FormEvent) => { const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault()
setError(''); setError('')
setLoading(true); setLoading(true)
const result = await verifyOTP(userID, otpCode)
const result = await verifyOTP(userID, otpCode);
if (!result.success) { if (!result.success) {
setError(result.message || t('verificationFailed', language)); setError(result.message || t('verificationFailed', language))
} }
// 成功的话AuthContext会自动处理登录状态 // 成功的话AuthContext会自动处理登录状态
setLoading(false); setLoading(false)
}; }
return ( return (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}> <div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
<HeaderBar <HeaderBar
onLoginClick={() => {}} onLoginClick={() => {}}
isLoggedIn={false} isLoggedIn={false}
isHomePage={false} isHomePage={false}
currentPage="login" currentPage="login"
language={language} language={language}
onLanguageChange={() => {}} onLanguageChange={() => {}}
onPageChange={(page) => { onPageChange={(page) => {
console.log('LoginPage onPageChange called with:', page); console.log('LoginPage onPageChange called with:', page)
if (page === 'competition') { 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"> <div className="w-full max-w-md">
{/* Logo */} {/* Logo */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center"> <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> </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 NOFX
</h1> </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' ? '请输入您的邮箱和密码' : '请输入两步验证码'} {step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
</p> </p>
</div> </div>
{/* Login Form */} {/* Login Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}> <div
{step === 'login' ? ( className="rounded-lg p-6"
<form onSubmit={handleLogin} className="space-y-4"> style={{
<div> background: 'var(--panel-bg)',
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}> border: '1px solid var(--panel-border)',
{t('email', language)} }}
</label> >
<input {step === 'login' ? (
type="email" <form onSubmit={handleLogin} className="space-y-4">
value={email} <div>
onChange={(e) => setEmail(e.target.value)} <label
className="w-full px-3 py-2 rounded" className="block text-sm font-semibold mb-2"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
placeholder={t('emailPlaceholder', language)} >
required {t('email', language)}
/> </label>
</div> <input
type="email"
<div> value={email}
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}> onChange={(e) => setEmail(e.target.value)}
{t('password', language)} className="w-full px-3 py-2 rounded"
</label> style={{
<input background: 'var(--brand-black)',
type="password" border: '1px solid var(--panel-border)',
value={password} color: 'var(--brand-light-gray)',
onChange={(e) => setPassword(e.target.value)} }}
className="w-full px-3 py-2 rounded" placeholder={t('emailPlaceholder', language)}
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }} required
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}
</div> </div>
)}
<button <div>
type="submit" <label
disabled={loading} className="block text-sm font-semibold mb-2"
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50" style={{ color: 'var(--brand-light-gray)' }}
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} >
> {t('password', language)}
{loading ? t('loading', language) : t('loginButton', language)} </label>
</button> <input
</form> type="password"
) : ( value={password}
<form onSubmit={handleOTPVerify} className="space-y-4"> onChange={(e) => setPassword(e.target.value)}
<div className="text-center mb-4"> className="w-full px-3 py-2 rounded"
<div className="text-4xl mb-2">📱</div> style={{
<p className="text-sm" style={{ color: '#848E9C' }}> background: 'var(--brand-black)',
{t('scanQRCodeInstructions', language)}<br /> border: '1px solid var(--panel-border)',
{t('enterOTPCode', language)} color: 'var(--brand-light-gray)',
</p> }}
</div> placeholder={t('passwordPlaceholder', language)}
required
<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>
)}
<div className="flex gap-3"> {error && (
<button <div
type="button" className="text-sm px-3 py-2 rounded"
onClick={() => setStep('login')} style={{
className="flex-1 px-4 py-2 rounded text-sm font-semibold" background: 'var(--binance-red-bg)',
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }} color: 'var(--binance-red)',
> }}
{t('back', language)} >
</button> {error}
</div>
)}
<button <button
type="submit" type="submit"
disabled={loading || otpCode.length !== 6} disabled={loading}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50" className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }} style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
> >
{loading ? t('loading', language) : t('verifyOTP', language)} {loading
? t('loading', language)
: t('loginButton', language)}
</button> </button>
</div> </form>
</form> ) : (
)} <form onSubmit={handleOTPVerify} className="space-y-4">
</div> <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>
<div className="text-center mt-6"> <label
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> className="block text-sm font-semibold mb-2"
{' '} style={{ color: 'var(--brand-light-gray)' }}
<button >
onClick={() => { {t('otpCode', language)}
window.history.pushState({}, '', '/register'); </label>
window.dispatchEvent(new PopStateEvent('popstate')); <input
}} type="text"
className="font-semibold hover:underline transition-colors" value={otpCode}
style={{ color: 'var(--brand-yellow)' }} onChange={(e) =>
> setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
</button> className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
</p> 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>
</div>
</div> </div>
); )
} }

View File

@@ -1,26 +1,25 @@
interface IconProps { interface IconProps {
width?: number; width?: number
height?: number; height?: number
className?: string; className?: string
} }
// 获取AI模型图标的函数 // 获取AI模型图标的函数
export const getModelIcon = (modelType: string, props: IconProps = {}) => { export const getModelIcon = (modelType: string, props: IconProps = {}) => {
// 支持完整ID或类型名 // 支持完整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) { switch (type) {
case 'deepseek': case 'deepseek':
iconPath = '/icons/deepseek.svg'; iconPath = '/icons/deepseek.svg'
break; break
case 'qwen': case 'qwen':
iconPath = '/icons/qwen.svg'; iconPath = '/icons/qwen.svg'
break; break
default: default:
return null; return null
} }
return ( return (
@@ -32,5 +31,5 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
className={props.className} className={props.className}
style={{ borderRadius: '50%' }} style={{ borderRadius: '50%' }}
/> />
); )
}; }

View File

@@ -1,366 +1,502 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
import { getSystemConfig } from '../lib/config'; import { getSystemConfig } from '../lib/config'
import HeaderBar from './landing/HeaderBar'; import HeaderBar from './landing/HeaderBar'
export function RegisterPage() { export function RegisterPage() {
const { language } = useLanguage(); const { language } = useLanguage()
const { register, completeRegistration } = useAuth(); const { register, completeRegistration } = useAuth()
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register'); const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
const [email, setEmail] = useState(''); 'register'
const [password, setPassword] = useState(''); )
const [confirmPassword, setConfirmPassword] = useState(''); const [email, setEmail] = useState('')
const [betaCode, setBetaCode] = useState(''); const [password, setPassword] = useState('')
const [betaMode, setBetaMode] = useState(false); const [confirmPassword, setConfirmPassword] = useState('')
const [otpCode, setOtpCode] = useState(''); const [betaCode, setBetaCode] = useState('')
const [userID, setUserID] = useState(''); const [betaMode, setBetaMode] = useState(false)
const [otpSecret, setOtpSecret] = useState(''); const [otpCode, setOtpCode] = useState('')
const [qrCodeURL, setQrCodeURL] = useState(''); const [userID, setUserID] = useState('')
const [error, setError] = useState(''); const [otpSecret, setOtpSecret] = useState('')
const [loading, setLoading] = useState(false); const [qrCodeURL, setQrCodeURL] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
// 获取系统配置,检查是否开启内测模式 // 获取系统配置,检查是否开启内测模式
getSystemConfig().then(config => { getSystemConfig()
setBetaMode(config.beta_mode || false); .then((config) => {
}).catch(err => { setBetaMode(config.beta_mode || false)
console.error('Failed to fetch system config:', err); })
}); .catch((err) => {
}, []); console.error('Failed to fetch system config:', err)
})
}, [])
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault()
setError(''); setError('')
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError(t('passwordMismatch', language)); setError(t('passwordMismatch', language))
return; return
} }
if (password.length < 6) { if (password.length < 6) {
setError(t('passwordTooShort', language)); setError(t('passwordTooShort', language))
return; return
} }
if (betaMode && !betaCode.trim()) { if (betaMode && !betaCode.trim()) {
setError('内测期间,注册需要提供内测码'); setError('内测期间,注册需要提供内测码')
return; 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) { if (result.success && result.userID) {
setUserID(result.userID); setUserID(result.userID)
setOtpSecret(result.otpSecret || ''); setOtpSecret(result.otpSecret || '')
setQrCodeURL(result.qrCodeURL || ''); setQrCodeURL(result.qrCodeURL || '')
setStep('setup-otp'); setStep('setup-otp')
} else { } else {
setError(result.message || t('registrationFailed', language)); setError(result.message || t('registrationFailed', language))
} }
setLoading(false); setLoading(false)
}; }
const handleSetupComplete = () => { const handleSetupComplete = () => {
setStep('verify-otp'); setStep('verify-otp')
}; }
const handleOTPVerify = async (e: React.FormEvent) => { const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault()
setError(''); setError('')
setLoading(true); setLoading(true)
const result = await completeRegistration(userID, otpCode)
const result = await completeRegistration(userID, otpCode);
if (!result.success) { if (!result.success) {
setError(result.message || t('registrationFailed', language)); setError(result.message || t('registrationFailed', language))
} }
// 成功的话AuthContext会自动处理登录状态 // 成功的话AuthContext会自动处理登录状态
setLoading(false); setLoading(false)
}; }
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text)
}; }
return ( return (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}> <div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
<HeaderBar <HeaderBar
isLoggedIn={false} isLoggedIn={false}
isHomePage={false} isHomePage={false}
currentPage="register" currentPage="register"
language={language} language={language}
onLanguageChange={() => {}} onLanguageChange={() => {}}
onPageChange={(page) => { onPageChange={(page) => {
console.log('RegisterPage onPageChange called with:', page); console.log('RegisterPage onPageChange called with:', page)
if (page === 'competition') { 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"> <div className="w-full max-w-md">
{/* Logo */} {/* Logo */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center"> <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: '#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> </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 */} {/* Registration Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}> <div
{step === 'register' && ( className="rounded-lg p-6"
<form onSubmit={handleRegister} className="space-y-4"> style={{
<div> background: 'var(--panel-bg)',
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}> border: '1px solid var(--panel-border)',
{t('email', language)} }}
</label> >
<input {step === 'register' && (
type="email" <form onSubmit={handleRegister} className="space-y-4">
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 && (
<div> <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> </label>
<input <input
type="text" type="email"
value={betaCode} value={email}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())} onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded font-mono" className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} style={{
placeholder="请输入6位内测码" background: 'var(--brand-black)',
maxLength={6} border: '1px solid var(--panel-border)',
required={betaMode} color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/> />
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
6
</p>
</div> </div>
)}
{error && ( <div>
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}> <label
{error} 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>
)}
<button <div>
type="submit" <label
disabled={loading || (betaMode && !betaCode.trim())} className="block text-sm font-semibold mb-2"
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50" style={{ color: 'var(--brand-light-gray)' }}
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} >
> {t('confirmPassword', language)}
{loading ? t('loading', language) : t('registerButton', language)} </label>
</button> <input
</form> 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' && ( {betaMode && (
<div className="space-y-4"> <div>
<div className="text-center"> <label
<div className="text-4xl mb-2">📱</div> className="block text-sm font-semibold mb-2"
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}> style={{ color: '#EAECEF' }}
{t('setupTwoFactor', language)} >
</h3> *
<p className="text-sm" style={{ color: '#848E9C' }}> </label>
{t('setupTwoFactorDesc', language)} <input
</p> type="text"
</div> 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"> {error && (
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}> <div
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}> className="text-sm px-3 py-2 rounded"
{t('authStep1Title', language)} style={{
</p> background: 'var(--binance-red-bg)',
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}> color: 'var(--binance-red)',
{t('authStep1Desc', language)} }}
>
{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> </p>
</div> </div>
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}> <div className="space-y-3">
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}> <div
{t('authStep2Title', language)} className="p-3 rounded"
</p> style={{
<p className="text-xs mb-2" style={{ color: '#848E9C' }}> background: 'var(--brand-black)',
{t('authStep2Desc', language)} border: '1px solid var(--panel-border)',
</p> }}
>
{qrCodeURL && ( <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>
)}
<div className="mt-2"> <div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p> <p className="text-xs mb-1" style={{ color: '#848E9C' }}>
<div className="bg-white p-2 rounded text-center"> {t('otpSecret', language)}
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`} </p>
alt="QR Code" className="mx-auto" /> <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>
)} </div>
<div className="mt-2"> <div
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p> className="p-3 rounded"
<div className="flex items-center gap-2"> style={{
<code className="flex-1 px-2 py-1 text-xs rounded font-mono" background: 'var(--brand-black)',
style={{ background: 'var(--panel-bg-hover)', color: 'var(--brand-light-gray)' }}> border: '1px solid var(--panel-border)',
{otpSecret} }}
</code> >
<button <p
onClick={() => copyToClipboard(otpSecret)} className="text-sm font-semibold mb-2"
className="px-2 py-1 text-xs rounded" style={{ color: 'var(--brand-light-gray)' }}
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} >
> {t('authStep3Title', language)}
{t('copy', language)} </p>
</button> <p
</div> className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep3Desc', language)}
</p>
</div> </div>
</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 <button
type="button" onClick={handleSetupComplete}
onClick={() => setStep('setup-otp')} className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
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' }} style={{ background: '#F0B90B', color: '#000' }}
> >
{loading ? t('loading', language) : t('completeRegistration', language)} {t('setupCompleteContinue', language)}
</button> </button>
</div> </div>
</form> )}
)}
</div>
{/* Login Link */} {step === 'verify-otp' && (
{step === 'register' && ( <form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mt-6"> <div className="text-center mb-4">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <div className="text-4xl mb-2">🔐</div>
{' '} <p className="text-sm" style={{ color: '#848E9C' }}>
<button {t('enterOTPCode', language)}
onClick={() => { <br />
window.history.pushState({}, '', '/login'); {t('completeRegistrationSubtitle', language)}
window.dispatchEvent(new PopStateEvent('popstate')); </p>
}} </div>
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }} <div>
> <label
className="block text-sm font-semibold mb-2"
</button> style={{ color: 'var(--brand-light-gray)' }}
</p> >
{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> </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> </div>
</div> </div>
); )
} }

View File

@@ -1,52 +1,52 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest } from '../types'; import type { AIModel, Exchange, CreateTraderRequest } from '../types'
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'; import { t } from '../i18n/translations'
// 提取下划线后面的名称部分 // 提取下划线后面的名称部分
function getShortName(fullName: string): string { function getShortName(fullName: string): string {
const parts = fullName.split('_'); const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName; return parts.length > 1 ? parts[parts.length - 1] : fullName
} }
interface TraderConfigData { interface TraderConfigData {
trader_id?: string; trader_id?: string
trader_name: string; trader_name: string
ai_model: string; ai_model: string
exchange_id: string; exchange_id: string
btc_eth_leverage: number; btc_eth_leverage: number
altcoin_leverage: number; altcoin_leverage: number
trading_symbols: string; trading_symbols: string
custom_prompt: string; custom_prompt: string
override_base_prompt: boolean; override_base_prompt: boolean
system_prompt_template: string; system_prompt_template: string
is_cross_margin: boolean; is_cross_margin: boolean
use_coin_pool: boolean; use_coin_pool: boolean
use_oi_top: boolean; use_oi_top: boolean
initial_balance: number; initial_balance: number
scan_interval_minutes: number; scan_interval_minutes: number
} }
interface TraderConfigModalProps { interface TraderConfigModalProps {
isOpen: boolean; isOpen: boolean
onClose: () => void; onClose: () => void
traderData?: TraderConfigData | null; traderData?: TraderConfigData | null
isEditMode?: boolean; isEditMode?: boolean
availableModels?: AIModel[]; availableModels?: AIModel[]
availableExchanges?: Exchange[]; availableExchanges?: Exchange[]
onSave?: (data: CreateTraderRequest) => Promise<void>; onSave?: (data: CreateTraderRequest) => Promise<void>
} }
export function TraderConfigModal({ export function TraderConfigModal({
isOpen, isOpen,
onClose, onClose,
traderData, traderData,
isEditMode = false, isEditMode = false,
availableModels = [], availableModels = [],
availableExchanges = [], availableExchanges = [],
onSave onSave,
}: TraderConfigModalProps) { }: TraderConfigModalProps) {
const { language } = useLanguage(); const { language } = useLanguage()
const [formData, setFormData] = useState<TraderConfigData>({ const [formData, setFormData] = useState<TraderConfigData>({
trader_name: '', trader_name: '',
ai_model: '', ai_model: '',
@@ -62,20 +62,23 @@ export function TraderConfigModal({
use_oi_top: false, use_oi_top: false,
initial_balance: 1000, initial_balance: 1000,
scan_interval_minutes: 3, scan_interval_minutes: 3,
}); })
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false)
const [availableCoins, setAvailableCoins] = useState<string[]>([]); const [availableCoins, setAvailableCoins] = useState<string[]>([])
const [selectedCoins, setSelectedCoins] = useState<string[]>([]); const [selectedCoins, setSelectedCoins] = useState<string[]>([])
const [showCoinSelector, setShowCoinSelector] = useState(false); const [showCoinSelector, setShowCoinSelector] = useState(false)
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]); const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
useEffect(() => { useEffect(() => {
if (traderData) { if (traderData) {
setFormData(traderData); setFormData(traderData)
// 设置已选择的币种 // 设置已选择的币种
if (traderData.trading_symbols) { if (traderData.trading_symbols) {
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s); const coins = traderData.trading_symbols
setSelectedCoins(coins); .split(',')
.map((s) => s.trim())
.filter((s) => s)
setSelectedCoins(coins)
} }
} else if (!isEditMode) { } else if (!isEditMode) {
setFormData({ setFormData({
@@ -93,85 +96,96 @@ export function TraderConfigModal({
use_oi_top: false, use_oi_top: false,
initial_balance: 1000, initial_balance: 1000,
scan_interval_minutes: 3, scan_interval_minutes: 3,
}); })
} }
// 确保旧数据也有默认的 system_prompt_template // 确保旧数据也有默认的 system_prompt_template
if (traderData && !traderData.system_prompt_template) { if (traderData && !traderData.system_prompt_template) {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
system_prompt_template: 'default' system_prompt_template: 'default',
})); }))
} }
}, [traderData, isEditMode, availableModels, availableExchanges]); }, [traderData, isEditMode, availableModels, availableExchanges])
// 获取系统配置中的币种列表 // 获取系统配置中的币种列表
useEffect(() => { useEffect(() => {
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const response = await fetch('/api/config'); const response = await fetch('/api/config')
const config = await response.json(); const config = await response.json()
if (config.default_coins) { if (config.default_coins) {
setAvailableCoins(config.default_coins); setAvailableCoins(config.default_coins)
} }
} catch (error) { } 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(() => { useEffect(() => {
const fetchPromptTemplates = async () => { const fetchPromptTemplates = async () => {
try { try {
const response = await fetch('/api/prompt-templates'); const response = await fetch('/api/prompt-templates')
const data = await response.json(); const data = await response.json()
if (data.templates) { if (data.templates) {
setPromptTemplates(data.templates); setPromptTemplates(data.templates)
} }
} catch (error) { } 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(() => { useEffect(() => {
const symbolsString = selectedCoins.join(','); const symbolsString = selectedCoins.join(',')
setFormData(prev => ({ ...prev, trading_symbols: symbolsString })); setFormData((prev) => ({ ...prev, trading_symbols: symbolsString }))
}, [selectedCoins]); }, [selectedCoins])
if (!isOpen) return null; if (!isOpen) return null
const handleInputChange = (field: keyof TraderConfigData, value: any) => { const handleInputChange = (field: keyof TraderConfigData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }))
// 如果是直接编辑trading_symbols同步更新selectedCoins // 如果是直接编辑trading_symbols同步更新selectedCoins
if (field === 'trading_symbols') { if (field === 'trading_symbols') {
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s); const coins = value
setSelectedCoins(coins); .split(',')
.map((s: string) => s.trim())
.filter((s: string) => s)
setSelectedCoins(coins)
} }
}; }
const handleCoinToggle = (coin: string) => { const handleCoinToggle = (coin: string) => {
setSelectedCoins(prev => { setSelectedCoins((prev) => {
if (prev.includes(coin)) { if (prev.includes(coin)) {
return prev.filter(c => c !== coin); return prev.filter((c) => c !== coin)
} else { } else {
return [...prev, coin]; return [...prev, coin]
} }
}); })
}; }
const handleSave = async () => { const handleSave = async () => {
if (!onSave) return; if (!onSave) return
setIsSaving(true); setIsSaving(true)
try { try {
const saveData: CreateTraderRequest = { const saveData: CreateTraderRequest = {
name: formData.trader_name, name: formData.trader_name,
@@ -188,19 +202,19 @@ export function TraderConfigModal({
use_oi_top: formData.use_oi_top, use_oi_top: formData.use_oi_top,
initial_balance: formData.initial_balance, initial_balance: formData.initial_balance,
scan_interval_minutes: formData.scan_interval_minutes, scan_interval_minutes: formData.scan_interval_minutes,
}; }
await onSave(saveData); await onSave(saveData)
onClose(); onClose()
} catch (error) { } catch (error) {
console.error('保存失败:', error); console.error('保存失败:', error)
} finally { } finally {
setIsSaving(false); setIsSaving(false)
} }
}; }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div <div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto" className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -236,24 +250,32 @@ export function TraderConfigModal({
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"></label> <label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input <input
type="text" type="text"
value={formData.trader_name} 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="请输入交易员名称" placeholder="请输入交易员名称"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2">AI模型</label> <label className="text-sm text-[#EAECEF] block mb-2">
AI模型
</label>
<select <select
value={formData.ai_model} 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" 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}> <option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()} {getShortName(model.name || model.id).toUpperCase()}
</option> </option>
@@ -261,15 +283,21 @@ export function TraderConfigModal({
</select> </select>
</div> </div>
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"></label> <label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select <select
value={formData.exchange_id} 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" 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}> <option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.id).toUpperCase()} {getShortName(
exchange.name || exchange.id
).toUpperCase()}
</option> </option>
))} ))}
</select> </select>
@@ -287,14 +315,16 @@ export function TraderConfigModal({
{/* 第一行:保证金模式和初始余额 */} {/* 第一行:保证金模式和初始余额 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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"> <div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => handleInputChange('is_cross_margin', true)} onClick={() => handleInputChange('is_cross_margin', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${ className={`flex-1 px-3 py-2 rounded text-sm ${
formData.is_cross_margin formData.is_cross_margin
? 'bg-[#F0B90B] text-black' ? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]' : 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`} }`}
> >
@@ -302,10 +332,12 @@ export function TraderConfigModal({
</button> </button>
<button <button
type="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 ${ className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.is_cross_margin !formData.is_cross_margin
? 'bg-[#F0B90B] text-black' ? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]' : 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`} }`}
> >
@@ -314,11 +346,18 @@ export function TraderConfigModal({
</div> </div>
</div> </div>
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> ($)</label> <label className="text-sm text-[#EAECEF] block mb-2">
($)
</label>
<input <input
type="number" type="number"
value={formData.initial_balance} 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100" min="100"
step="100" step="100"
@@ -329,17 +368,26 @@ export function TraderConfigModal({
{/* 第二行AI 扫描决策间隔 */} {/* 第二行AI 扫描决策间隔 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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 <input
type="number" type="number"
value={formData.scan_interval_minutes} 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1" min="1"
max="60" max="60"
step="1" 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> <div></div>
</div> </div>
@@ -347,22 +395,36 @@ export function TraderConfigModal({
{/* 第三行:杠杆设置 */} {/* 第三行:杠杆设置 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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 <input
type="number" type="number"
value={formData.btc_eth_leverage} 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1" min="1"
max="125" max="125"
/> />
</div> </div>
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"></label> <label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input <input
type="number" type="number"
value={formData.altcoin_leverage} 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1" min="1"
max="75" max="75"
@@ -373,7 +435,9 @@ export function TraderConfigModal({
{/* 第三行:交易币种 */} {/* 第三行:交易币种 */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]"> (使)</label> <label className="text-sm text-[#EAECEF]">
(使)
</label>
<button <button
type="button" type="button"
onClick={() => setShowCoinSelector(!showCoinSelector)} onClick={() => setShowCoinSelector(!showCoinSelector)}
@@ -385,17 +449,21 @@ export function TraderConfigModal({
<input <input
type="text" type="text"
value={formData.trading_symbols} 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" 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" placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
/> />
{/* 币种选择器 */} {/* 币种选择器 */}
{showCoinSelector && ( {showCoinSelector && (
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded"> <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"> <div className="flex flex-wrap gap-2">
{availableCoins.map(coin => ( {availableCoins.map((coin) => (
<button <button
key={coin} key={coin}
type="button" type="button"
@@ -426,19 +494,27 @@ export function TraderConfigModal({
<input <input
type="checkbox" type="checkbox"
checked={formData.use_coin_pool} 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" 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>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="checkbox" type="checkbox"
checked={formData.use_oi_top} 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" 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> </div>
</div> </div>
@@ -451,17 +527,24 @@ export function TraderConfigModal({
<div className="space-y-4"> <div className="space-y-4">
{/* 系统提示词模板选择 */} {/* 系统提示词模板选择 */}
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"></label> <label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select <select
value={formData.system_prompt_template} 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" 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}> <option key={template.name} value={template.name}>
{template.name === 'default' ? 'Default (默认稳健)' : {template.name === 'default'
template.name === 'aggressive' ? 'Aggressive (激进)' : ? 'Default (默认稳健)'
template.name.charAt(0).toUpperCase() + template.name.slice(1)} : template.name === 'aggressive'
? 'Aggressive (激进)'
: template.name.charAt(0).toUpperCase() +
template.name.slice(1)}
</option> </option>
))} ))}
</select> </select>
@@ -474,21 +557,47 @@ export function TraderConfigModal({
<input <input
type="checkbox" type="checkbox"
checked={formData.override_base_prompt} 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" className="w-4 h-4"
/> />
<label className="text-sm text-[#EAECEF]"></label> <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>
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'} {formData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</label> </label>
<textarea <textarea
value={formData.custom_prompt} 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" 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>
</div> </div>
@@ -506,14 +615,19 @@ export function TraderConfigModal({
{onSave && ( {onSave && (
<button <button
onClick={handleSave} 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" 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> </button>
)} )}
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,57 +1,70 @@
import { useState } from 'react'; import { useState } from 'react'
import type { TraderConfigData } from '../types'; import type { TraderConfigData } from '../types'
// 提取下划线后面的名称部分 // 提取下划线后面的名称部分
function getShortName(fullName: string): string { function getShortName(fullName: string): string {
const parts = fullName.split('_'); const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName; return parts.length > 1 ? parts[parts.length - 1] : fullName
} }
interface TraderConfigViewModalProps { interface TraderConfigViewModalProps {
isOpen: boolean; isOpen: boolean
onClose: () => void; onClose: () => void
traderData?: TraderConfigData | null; traderData?: TraderConfigData | null
} }
export function TraderConfigViewModal({ export function TraderConfigViewModal({
isOpen, isOpen,
onClose, onClose,
traderData traderData,
}: TraderConfigViewModalProps) { }: 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) => { const copyToClipboard = async (text: string, fieldName: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
setCopiedField(fieldName); setCopiedField(fieldName)
setTimeout(() => setCopiedField(null), 2000); setTimeout(() => setCopiedField(null), 2000)
} catch (error) { } 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 <button
onClick={() => copyToClipboard(text, fieldName)} onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105" className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
style={{ 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', 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 ? '✓ 已复制' : '📋 复制'} {copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
</button> </button>
); )
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: { const InfoRow = ({
label: string; label,
value: string | number | boolean; value,
copyable?: boolean; copyable = false,
fieldName?: string; 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"> <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> <span className="text-sm text-[#848E9C] font-medium">{label}</span>
@@ -64,11 +77,11 @@ export function TraderConfigViewModal({
)} )}
</div> </div>
</div> </div>
); )
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div <div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -79,9 +92,7 @@ export function TraderConfigViewModal({
<span className="text-lg">👁</span> <span className="text-lg">👁</span>
</div> </div>
<div> <div>
<h2 className="text-xl font-bold text-[#EAECEF]"> <h2 className="text-xl font-bold text-[#EAECEF]"></h2>
</h2>
<p className="text-sm text-[#848E9C] mt-1"> <p className="text-sm text-[#848E9C] mt-1">
{traderData.trader_name} {traderData.trader_name}
</p> </p>
@@ -91,9 +102,10 @@ export function TraderConfigViewModal({
{/* Running Status */} {/* Running Status */}
<div <div
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1" className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
style={traderData.is_running style={
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' } traderData.is_running
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' } ? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
} }
> >
<span>{traderData.is_running ? '●' : '○'}</span> <span>{traderData.is_running ? '●' : '○'}</span>
@@ -116,11 +128,30 @@ export function TraderConfigViewModal({
🤖 🤖
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" /> <InfoRow
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" /> label="交易员ID"
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} /> value={traderData.trader_id || ''}
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} /> copyable
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} /> 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>
</div> </div>
@@ -130,14 +161,23 @@ export function TraderConfigViewModal({
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} /> <InfoRow
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} /> label="保证金模式"
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} /> value={traderData.is_cross_margin ? '全仓' : '逐仓'}
<InfoRow />
label="交易币种" <InfoRow
value={traderData.trading_symbols || '使用默认币种'} label="BTC/ETH 杠杆"
copyable value={`${traderData.btc_eth_leverage}x`}
fieldName="trading_symbols" />
<InfoRow
label="山寨币杠杆"
value={`${traderData.altcoin_leverage}x`}
/>
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
copyable
fieldName="trading_symbols"
/> />
</div> </div>
</div> </div>
@@ -148,7 +188,10 @@ export function TraderConfigViewModal({
📡 📡
</h3> </h3>
<div className="space-y-3"> <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} /> <InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
</div> </div>
</div> </div>
@@ -160,29 +203,41 @@ export function TraderConfigViewModal({
💬 💬
</h3> </h3>
{traderData.custom_prompt && ( {traderData.custom_prompt && (
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" /> <CopyButton
text={traderData.custom_prompt}
fieldName="custom_prompt"
/>
)} )}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} /> <InfoRow
label="覆盖默认提示词"
value={traderData.override_base_prompt}
/>
{traderData.custom_prompt ? ( {traderData.custom_prompt ? (
<div> <div>
<div className="text-sm text-[#848E9C] mb-2"> <div className="text-sm text-[#848E9C] mb-2">
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'} {traderData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</div> </div>
<div <div
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto" className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
style={{ style={{
background: '#0B0E11', background: '#0B0E11',
border: '1px solid #2B3139', border: '1px solid #2B3139',
whiteSpace: 'pre-wrap' whiteSpace: 'pre-wrap',
}} }}
> >
{traderData.custom_prompt} {traderData.custom_prompt}
</div> </div>
</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> </div>
)} )}
@@ -199,7 +254,12 @@ export function TraderConfigViewModal({
</button> </button>
<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" 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' ? '✓ 已复制配置' : '📋 复制完整配置'} {copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
@@ -207,5 +267,5 @@ export function TraderConfigViewModal({
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@@ -21,16 +21,25 @@ export default function Typewriter({
const charIndexRef = useRef(0) const charIndexRef = useRef(0)
const timerRef = useRef<number | null>(null) const timerRef = useRef<number | null>(null)
const blinkRef = 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(() => { useEffect(() => {
// 重置状态
lineIndexRef.current = 0
charIndexRef.current = 0
setTypedLines([''])
function typeNext() { function typeNext() {
const currentLine = sanitizedLines[lineIndexRef.current] ?? '' const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
if (charIndexRef.current < currentLine.length) { if (charIndexRef.current < currentLine.length) {
const ch = currentLine.charAt(charIndexRef.current)
setTypedLines((prev) => { setTypedLines((prev) => {
const next = [...prev] const next = [...prev]
const ch = currentLine.charAt(charIndexRef.current) const lastIndex = next.length - 1
next[next.length - 1] = (next[next.length - 1] || '') + ch next[lastIndex] = (next[lastIndex] ?? '') + ch
return next return next
}) })
charIndexRef.current += 1 charIndexRef.current += 1
@@ -49,7 +58,8 @@ export default function Typewriter({
} }
} }
typeNext() // 延迟一帧开始打字,确保状态已重置
timerRef.current = window.setTimeout(typeNext, 0)
// 光标闪烁 // 光标闪烁
blinkRef.current = window.setInterval(() => { blinkRef.current = window.setInterval(() => {
@@ -60,9 +70,12 @@ export default function Typewriter({
if (timerRef.current) window.clearTimeout(timerRef.current) if (timerRef.current) window.clearTimeout(timerRef.current)
if (blinkRef.current) window.clearInterval(blinkRef.current) if (blinkRef.current) window.clearInterval(blinkRef.current)
} }
}, [lines, typingSpeed, lineDelay]) }, [sanitizedLines, typingSpeed, lineDelay])
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines]) const displayText = useMemo(
() => typedLines.join('\n').replace(/undefined/g, ''),
[typedLines]
)
return ( return (
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}> <pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>

View File

@@ -10,18 +10,18 @@ interface AboutSectionProps {
export default function AboutSection({ language }: AboutSectionProps) { export default function AboutSection({ language }: AboutSectionProps) {
return ( return (
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'> <AnimatedSection id="about" backgroundColor="var(--brand-dark-gray)">
<div className='max-w-7xl mx-auto'> <div className="max-w-7xl mx-auto">
<div className='grid lg:grid-cols-2 gap-12 items-center'> <div className="grid lg:grid-cols-2 gap-12 items-center">
<motion.div <motion.div
className='space-y-6' className="space-y-6"
initial={{ opacity: 0, x: -50 }} initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
> >
<motion.div <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={{ style={{
background: 'rgba(240, 185, 11, 0.1)', background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)', border: '1px solid rgba(240, 185, 11, 0.2)',
@@ -29,11 +29,11 @@ export default function AboutSection({ language }: AboutSectionProps) {
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
> >
<Target <Target
className='w-4 h-4' className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }} style={{ color: 'var(--brand-yellow)' }}
/> />
<span <span
className='text-sm font-semibold' className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }} style={{ color: 'var(--brand-yellow)' }}
> >
{t('aboutNofx', language)} {t('aboutNofx', language)}
@@ -41,45 +41,49 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div> </motion.div>
<h2 <h2
className='text-4xl font-bold' className="text-4xl font-bold"
style={{ color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
> >
{t('whatIsNofx', language)} {t('whatIsNofx', language)}
</h2> </h2>
<p <p
className='text-lg leading-relaxed' className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }} 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>
<p <p
className='text-lg leading-relaxed' className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }} style={{ color: 'var(--text-secondary)' }}
> >
{t('nofxDescription3', language)} {t('nofxDescription4', language)} {t('nofxDescription5', language)} {t('nofxDescription3', language)}{' '}
{t('nofxDescription4', language)}{' '}
{t('nofxDescription5', language)}
</p> </p>
<motion.div <motion.div
className='flex items-center gap-3 pt-4' className="flex items-center gap-3 pt-4"
whileHover={{ x: 5 }} whileHover={{ x: 5 }}
> >
<div <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)' }} style={{ background: 'rgba(240, 185, 11, 0.1)' }}
> >
<Shield <Shield
className='w-6 h-6' className="w-6 h-6"
style={{ color: 'var(--brand-yellow)' }} style={{ color: 'var(--brand-yellow)' }}
/> />
</div> </div>
<div> <div>
<div <div
className='font-semibold' className="font-semibold"
style={{ color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
> >
{t('youFullControl', language)} {t('youFullControl', language)}
</div> </div>
<div <div
className='text-sm' className="text-sm"
style={{ color: 'var(--text-secondary)' }} style={{ color: 'var(--text-secondary)' }}
> >
{t('fullControlDesc', language)} {t('fullControlDesc', language)}
@@ -88,9 +92,9 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div> </motion.div>
</motion.div> </motion.div>
<div className='relative'> <div className="relative">
<div <div
className='rounded-2xl p-8' className="rounded-2xl p-8"
style={{ style={{
background: 'var(--brand-black)', background: 'var(--brand-black)',
border: '1px solid var(--panel-border)', border: '1px solid var(--panel-border)',
@@ -108,7 +112,7 @@ export default function AboutSection({ language }: AboutSectionProps) {
]} ]}
typingSpeed={70} typingSpeed={70}
lineDelay={900} lineDelay={900}
className='text-sm font-mono' className="text-sm font-mono"
style={{ style={{
color: '#00FF88', color: '#00FF88',
textShadow: '0 0 8px rgba(0,255,136,0.4)', textShadow: '0 0 8px rgba(0,255,136,0.4)',
@@ -121,4 +125,3 @@ export default function AboutSection({ language }: AboutSectionProps) {
</AnimatedSection> </AnimatedSection>
) )
} }

View File

@@ -17,7 +17,7 @@ export default function AnimatedSection({
<motion.section <motion.section
id={id} id={id}
ref={ref} ref={ref}
className='py-20 px-4' className="py-20 px-4"
style={{ background: backgroundColor }} style={{ background: backgroundColor }}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : { opacity: 0 }} animate={isInView ? { opacity: 1 } : { opacity: 0 }}
@@ -27,4 +27,3 @@ export default function AnimatedSection({
</motion.section> </motion.section>
) )
} }

View File

@@ -2,31 +2,40 @@ import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection' import AnimatedSection from './AnimatedSection'
interface CardProps { interface CardProps {
quote: string; quote: string
authorName: string; authorName: string
handle: string; handle: string
avatarUrl: string; avatarUrl: string
tweetUrl: string; tweetUrl: string
delay: number; delay: number
} }
function TestimonialCard({ quote, authorName, delay }: CardProps) { function TestimonialCard({ quote, authorName, delay }: CardProps) {
return ( return (
<motion.div <motion.div
className='p-6 rounded-xl' className="p-6 rounded-xl"
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.1)' }} style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.1)',
}}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay }} transition={{ delay }}
whileHover={{ scale: 1.05 }} 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}" "{quote}"
</p> </p>
<div className='flex items-center gap-2'> <div className="flex items-center gap-2">
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} /> <div
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}> className="w-8 h-8 rounded-full"
style={{ background: 'var(--binance-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-secondary)' }}
>
{authorName} {authorName}
</span> </span>
</div> </div>
@@ -35,7 +44,9 @@ function TestimonialCard({ quote, authorName, delay }: CardProps) {
} }
export default function CommunitySection() { export default function CommunitySection() {
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } } const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
// 推特内容整合(保持原三列布局,超出自动换行) // 推特内容整合(保持原三列布局,超出自动换行)
const items: CardProps[] = [ const items: CardProps[] = [
@@ -74,12 +85,12 @@ export default function CommunitySection() {
return ( return (
<AnimatedSection> <AnimatedSection>
<div className='max-w-7xl mx-auto'> <div className="max-w-7xl mx-auto">
<motion.div <motion.div
className='grid md:grid-cols-3 gap-6' className="grid md:grid-cols-3 gap-6"
variants={staggerContainer} variants={staggerContainer}
initial='initial' initial="initial"
whileInView='animate' whileInView="animate"
viewport={{ once: true }} viewport={{ once: true }}
> >
{items.map((item, idx) => ( {items.map((item, idx) => (

View File

@@ -10,61 +10,78 @@ interface FeaturesSectionProps {
export default function FeaturesSection({ language }: FeaturesSectionProps) { export default function FeaturesSection({ language }: FeaturesSectionProps) {
return ( return (
<AnimatedSection id='features'> <AnimatedSection id="features">
<div className='max-w-7xl mx-auto'> <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="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<motion.div <motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6' 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)' }} style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
> >
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} /> <Rocket
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}> className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{t('coreFeatures', language)} {t('coreFeatures', language)}
</span> </span>
</motion.div> </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)} {t('whyChooseNofx', language)}
</h2> </h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}> <p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('openCommunityDriven', language)} {t('openCommunityDriven', language)}
</p> </p>
</motion.div> </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 <CryptoFeatureCard
icon={<Code className='w-8 h-8' />} icon={<Code className="w-8 h-8" />}
title={t('openSourceSelfHosted', language)} title={t('openSourceSelfHosted', language)}
description={t('openSourceDesc', language)} description={t('openSourceDesc', language)}
features={[ features={[
t('openSourceFeatures1', language), t('openSourceFeatures1', language),
t('openSourceFeatures2', language), t('openSourceFeatures2', language),
t('openSourceFeatures3', language), t('openSourceFeatures3', language),
t('openSourceFeatures4', language) t('openSourceFeatures4', language),
]} ]}
delay={0} delay={0}
/> />
<CryptoFeatureCard <CryptoFeatureCard
icon={<Cpu className='w-8 h-8' />} icon={<Cpu className="w-8 h-8" />}
title={t('multiAgentCompetition', language)} title={t('multiAgentCompetition', language)}
description={t('multiAgentDesc', language)} description={t('multiAgentDesc', language)}
features={[ features={[
t('multiAgentFeatures1', language), t('multiAgentFeatures1', language),
t('multiAgentFeatures2', language), t('multiAgentFeatures2', language),
t('multiAgentFeatures3', language), t('multiAgentFeatures3', language),
t('multiAgentFeatures4', language) t('multiAgentFeatures4', language),
]} ]}
delay={0.1} delay={0.1}
/> />
<CryptoFeatureCard <CryptoFeatureCard
icon={<Lock className='w-8 h-8' />} icon={<Lock className="w-8 h-8" />}
title={t('secureReliableTrading', language)} title={t('secureReliableTrading', language)}
description={t('secureDesc', language)} description={t('secureDesc', language)}
features={[ features={[
t('secureFeatures1', language), t('secureFeatures1', language),
t('secureFeatures2', language), t('secureFeatures2', language),
t('secureFeatures3', language), t('secureFeatures3', language),
t('secureFeatures4', language) t('secureFeatures4', language),
]} ]}
delay={0.2} delay={0.2}
/> />
@@ -73,4 +90,3 @@ export default function FeaturesSection({ language }: FeaturesSectionProps) {
</AnimatedSection> </AnimatedSection>
) )
} }

View File

@@ -6,57 +6,62 @@ interface FooterSectionProps {
export default function FooterSection({ language }: FooterSectionProps) { export default function FooterSection({ language }: FooterSectionProps) {
return ( return (
<footer style={{ borderTop: '1px solid var(--panel-border)', background: 'var(--brand-dark-gray)' }}> <footer
<div className='max-w-[1200px] mx-auto px-6 py-10'> style={{
borderTop: '1px solid var(--panel-border)',
background: 'var(--brand-dark-gray)',
}}
>
<div className="max-w-[1200px] mx-auto px-6 py-10">
{/* Brand */} {/* Brand */}
<div className='flex items-center gap-3 mb-8'> <div className="flex items-center gap-3 mb-8">
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' /> <img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<div> <div>
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}> <div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
NOFX NOFX
</div> </div>
<div className='text-xs' style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{t('futureStandardAI', language)} {t('futureStandardAI', language)}
</div> </div>
</div> </div>
</div> </div>
{/* Multi-link columns */} {/* 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> <div>
<h3 <h3
className='text-sm font-semibold mb-3' className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{t('links', language)} {t('links', language)}
</h3> </h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}> <ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://github.com/tinkle-community/nofx' href="https://github.com/tinkle-community/nofx"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
GitHub GitHub
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://t.me/nofx_dev_community' href="https://t.me/nofx_dev_community"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Telegram Telegram
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://x.com/nofx_ai' href="https://x.com/nofx_ai"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
X (Twitter) X (Twitter)
</a> </a>
@@ -66,38 +71,38 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div> <div>
<h3 <h3
className='text-sm font-semibold mb-3' className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{t('resources', language)} {t('resources', language)}
</h3> </h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}> <ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://github.com/tinkle-community/nofx/blob/main/README.md' href="https://github.com/tinkle-community/nofx/blob/main/README.md"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
{t('documentation', language)} {t('documentation', language)}
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://github.com/tinkle-community/nofx/issues' href="https://github.com/tinkle-community/nofx/issues"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Issues Issues
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://github.com/tinkle-community/nofx/pulls' href="https://github.com/tinkle-community/nofx/pulls"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Pull Requests Pull Requests
</a> </a>
@@ -107,50 +112,53 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div> <div>
<h3 <h3
className='text-sm font-semibold mb-3' className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }} style={{ color: '#EAECEF' }}
> >
{t('supporters', language)} {t('supporters', language)}
</h3> </h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}> <ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://asterdex.com/' href="https://asterdex.com/"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Aster DEX Aster DEX
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://www.binance.com/' href="https://www.binance.com/"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Binance Binance
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://hyperliquid.xyz/' href="https://hyperliquid.xyz/"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Hyperliquid Hyperliquid
</a> </a>
</li> </li>
<li> <li>
<a <a
className='hover:text-[#F0B90B]' className="hover:text-[#F0B90B]"
href='https://amber.ac/' href="https://amber.ac/"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
> >
Amber.ac <span className='opacity-70'>{t('strategicInvestment', language)}</span> Amber.ac{' '}
<span className="opacity-70">
{t('strategicInvestment', language)}
</span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -159,11 +167,14 @@ export default function FooterSection({ language }: FooterSectionProps) {
{/* Bottom note (kept subtle) */} {/* Bottom note (kept subtle) */}
<div <div
className='pt-6 mt-8 text-center text-xs' className="pt-6 mt-8 text-center text-xs"
style={{ color: 'var(--text-tertiary)', borderTop: '1px solid var(--panel-border)' }} style={{
color: 'var(--text-tertiary)',
borderTop: '1px solid var(--panel-border)',
}}
> >
<p>{t('footerTitle', language)}</p> <p>{t('footerTitle', language)}</p>
<p className='mt-1'>{t('footerWarning', language)}</p> <p className="mt-1">{t('footerWarning', language)}</p>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -16,7 +16,17 @@ interface HeaderBarProps {
onPageChange?: (page: string) => void 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 [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = 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 // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { 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) setLanguageDropdownOpen(false)
} }
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node)) { if (
userDropdownRef.current &&
!userDropdownRef.current.contains(event.target as Node)
) {
setUserDropdownOpen(false) setUserDropdownOpen(false)
} }
} }
@@ -41,231 +57,311 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
}, []) }, [])
return ( return (
<nav className='fixed top-0 w-full z-50 header-bar'> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className='flex items-center justify-between h-16'> <div className="flex items-center justify-between h-16">
{/* Logo */} {/* Logo */}
<a href='/' className='flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer'> <a
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' /> href="/"
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}> 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 NOFX
</span> </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 Agentic Trading OS
</span> </span>
</a> </a>
{/* Desktop Menu */} {/* 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 */} {/* Left Side - Navigation Tabs */}
<div className='flex items-center gap-4'> <div className="flex items-center gap-4">
{isLoggedIn ? ( {isLoggedIn ? (
// Main app navigation when logged in // Main app navigation when logged in
<> <>
<button <button
onClick={() => { onClick={() => {
console.log('实时 button clicked, onPageChange:', onPageChange); console.log(
onPageChange?.('competition'); '实时 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={{ style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative' position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (currentPage !== 'competition') { if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)'; e.currentTarget.style.color = 'var(--brand-yellow)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (currentPage !== 'competition') { if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)'; e.currentTarget.style.color = 'var(--brand-light-gray)'
} }
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'competition' && ( {currentPage === 'competition' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('realtimeNav', language)} {t('realtimeNav', language)}
</button> </button>
<button <button
onClick={() => { onClick={() => {
console.log('配置 button clicked, onPageChange:', onPageChange); console.log(
onPageChange?.('traders'); '配置 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={{ style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative' position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (currentPage !== 'traders') { if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-yellow)'; e.currentTarget.style.color = 'var(--brand-yellow)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (currentPage !== 'traders') { if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-light-gray)'; e.currentTarget.style.color = 'var(--brand-light-gray)'
} }
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'traders' && ( {currentPage === 'traders' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('configNav', language)} {t('configNav', language)}
</button> </button>
<button <button
onClick={() => { onClick={() => {
console.log('看板 button clicked, onPageChange:', onPageChange); console.log(
onPageChange?.('trader'); '看板 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={{ style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative' position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (currentPage !== 'trader') { if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-yellow)'; e.currentTarget.style.color = 'var(--brand-yellow)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (currentPage !== 'trader') { if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-light-gray)'; e.currentTarget.style.color = 'var(--brand-light-gray)'
} }
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'trader' && ( {currentPage === 'trader' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('dashboardNav', language)} {t('dashboardNav', language)}
</button> </button>
</> </>
) : ( ) : (
// Landing page navigation when not logged in // Landing page navigation when not logged in
<a <a
href='/competition' href="/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={{ style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative' position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (currentPage !== 'competition') { if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)'; e.currentTarget.style.color = 'var(--brand-yellow)'
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (currentPage !== 'competition') { if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)'; e.currentTarget.style.color = 'var(--brand-light-gray)'
} }
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'competition' && ( {currentPage === 'competition' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('realtimeNav', language)} {t('realtimeNav', language)}
</a> </a>
)} )}
</div> </div>
{/* Right Side - Original Navigation Items and Login */} {/* 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 */} {/* Only show original navigation items on home page */}
{isHomePage && [ {isHomePage &&
{ key: 'features', label: t('features', language) }, [
{ key: 'howItWorks', label: t('howItWorks', language) }, { key: 'features', label: t('features', language) },
{ key: 'GitHub', label: 'GitHub' }, { key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'community', label: t('community', language) } { key: 'GitHub', label: 'GitHub' },
].map((item) => ( { key: 'community', label: t('community', language) },
<a ].map((item) => (
key={item.key} <a
href={ key={item.key}
item.key === 'GitHub' href={
? 'https://github.com/tinkle-community/nofx' item.key === 'GitHub'
: item.key === 'community' ? 'https://github.com/tinkle-community/nofx'
? 'https://t.me/nofx_dev_community' : item.key === 'community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}` ? '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} target={
className='text-sm transition-colors relative group' item.key === 'GitHub' || item.key === 'community'
style={{ color: 'var(--brand-light-gray)' }} ? '_blank'
> : undefined
{item.label} }
<span rel={
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300' item.key === 'GitHub' || item.key === 'community'
style={{ background: 'var(--brand-yellow)' }} ? 'noopener noreferrer'
/> : undefined
</a> }
))} 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 */} {/* User Info and Actions */}
{isLoggedIn && user ? ( {isLoggedIn && user ? (
<div className='flex items-center gap-3'> <div className="flex items-center gap-3">
{/* User Info with Dropdown */} {/* User Info with Dropdown */}
<div className='relative' ref={userDropdownRef}> <div className="relative" ref={userDropdownRef}>
<button <button
onClick={() => setUserDropdownOpen(!userDropdownOpen)} onClick={() => setUserDropdownOpen(!userDropdownOpen)}
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={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }} style={{
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'} background: 'var(--panel-bg)',
onMouseLeave={(e) => e.currentTarget.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()} {user.email[0].toUpperCase()}
</div> </div>
<span className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</span> <span
<ChevronDown className='w-4 h-4' style={{ color: 'var(--brand-light-gray)' }} /> 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> </button>
{userDropdownOpen && ( {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
<div className='px-3 py-2 border-b' style={{ borderColor: 'var(--panel-border)' }}> className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div> style={{
<div className='text-sm font-medium' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div> 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> </div>
{!isAdminMode && onLogout && ( {!isAdminMode && onLogout && (
<button <button
@@ -273,10 +369,13 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout() onLogout()
setUserDropdownOpen(false) setUserDropdownOpen(false)
}} }}
className='w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center' 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)' }} style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
> >
{t('exitLogin', language)} {t('exitLogin', language)}
</button> </button>
)} )}
</div> </div>
@@ -285,43 +384,58 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</div> </div>
) : ( ) : (
/* Show login/register buttons when not logged in and not on login/register pages */ /* Show login/register buttons when not logged in and not on login/register pages */
currentPage !== 'login' && currentPage !== 'register' && ( currentPage !== 'login' &&
<div className='flex items-center gap-3'> currentPage !== 'register' && (
<div className="flex items-center gap-3">
<a <a
href='/login' href="/login"
className='px-3 py-2 text-sm font-medium transition-colors rounded' className="px-3 py-2 text-sm font-medium transition-colors rounded"
style={{ color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
> >
{t('signIn', language)} {t('signIn', language)}
</a> </a>
<a <a
href='/register' href="/register"
className='px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90' className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
> >
{t('signUp', language)} {t('signUp', language)}
</a> </a>
</div> </div>
) )
)} )}
{/* Language Toggle - Always at the rightmost */} {/* Language Toggle - Always at the rightmost */}
<div className='relative' ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)} 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)' }} style={{ color: 'var(--brand-light-gray)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'} onMouseEnter={(e) =>
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} (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' ? '🇨🇳' : '🇺🇸'} {language === 'zh' ? '🇨🇳' : '🇺🇸'}
</span> </span>
<ChevronDown className='w-4 h-4' /> <ChevronDown className="w-4 h-4" />
</button> </button>
{languageDropdownOpen && ( {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 <button
onClick={() => { onClick={() => {
onLanguageChange?.('zh') onLanguageChange?.('zh')
@@ -330,13 +444,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${ className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
language === 'zh' ? '' : 'hover:opacity-80' language === 'zh' ? '' : 'hover:opacity-80'
}`} }`}
style={{ style={{
color: 'var(--brand-light-gray)', 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-base">🇨🇳</span>
<span className='text-sm'></span> <span className="text-sm"></span>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -346,13 +463,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${ className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
language === 'en' ? '' : 'hover:opacity-80' language === 'en' ? '' : 'hover:opacity-80'
}`} }`}
style={{ style={{
color: 'var(--brand-light-gray)', 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-base">🇺🇸</span>
<span className='text-sm'>English</span> <span className="text-sm">English</span>
</button> </button>
</div> </div>
)} )}
@@ -363,11 +483,15 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<motion.button <motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className='md:hidden' className="md:hidden"
style={{ color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
whileTap={{ scale: 0.9 }} 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> </motion.button>
</div> </div>
</div> </div>
@@ -375,65 +499,81 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu */} {/* Mobile Menu */}
<motion.div <motion.div
initial={false} 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 }} transition={{ duration: 0.3 }}
className='md:hidden overflow-hidden' className="md:hidden overflow-hidden"
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }} 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 */} {/* New Navigation Tabs */}
{isLoggedIn ? ( {isLoggedIn ? (
<button <button
onClick={() => { onClick={() => {
console.log('移动端 实时 button clicked, onPageChange:', onPageChange); console.log(
'移动端 实时 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('competition') onPageChange?.('competition')
setMobileMenuOpen(false) 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={{ style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px', padding: '12px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative', position: 'relative',
width: '100%', width: '100%',
textAlign: 'left' textAlign: 'left',
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'competition' && ( {currentPage === 'competition' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('realtimeNav', language)} {t('realtimeNav', language)}
</button> </button>
) : ( ) : (
<a <a
href='/competition' href="/competition"
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={{ style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px', padding: '12px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative' position: 'relative',
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'competition' && ( {currentPage === 'competition' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('realtimeNav', language)} {t('realtimeNav', language)}
</a> </a>
)} )}
@@ -442,107 +582,135 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
<> <>
<button <button
onClick={() => { onClick={() => {
console.log('移动端 配置 button clicked, onPageChange:', onPageChange); console.log(
'移动端 配置 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('traders') onPageChange?.('traders')
setMobileMenuOpen(false) 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={{ style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px', padding: '12px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative', position: 'relative',
width: '100%', width: '100%',
textAlign: 'left' textAlign: 'left',
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'traders' && ( {currentPage === 'traders' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('configNav', language)} {t('configNav', language)}
</button> </button>
<button <button
onClick={() => { onClick={() => {
console.log('移动端 看板 button clicked, onPageChange:', onPageChange); console.log(
'移动端 看板 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('trader') onPageChange?.('trader')
setMobileMenuOpen(false) 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={{ style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)', color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px', padding: '12px 16px',
borderRadius: '8px', borderRadius: '8px',
position: 'relative', position: 'relative',
width: '100%', width: '100%',
textAlign: 'left' textAlign: 'left',
}} }}
> >
{/* Background for selected state */} {/* Background for selected state */}
{currentPage === 'trader' && ( {currentPage === 'trader' && (
<span <span
className="absolute inset-0 rounded-lg" className="absolute inset-0 rounded-lg"
style={{ style={{
background: 'rgba(240, 185, 11, 0.15)', background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1 zIndex: -1,
}} }}
/> />
)} )}
{t('dashboardNav', language)} {t('dashboardNav', language)}
</button> </button>
</> </>
)} )}
{/* Original Navigation Items - Only on home page */} {/* Original Navigation Items - Only on home page */}
{isHomePage && [ {isHomePage &&
{ key: 'features', label: t('features', language) }, [
{ key: 'howItWorks', label: t('howItWorks', language) }, { key: 'features', label: t('features', language) },
{ key: 'GitHub', label: 'GitHub' }, { key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'community', label: t('community', language) } { key: 'GitHub', label: 'GitHub' },
].map((item) => ( { key: 'community', label: t('community', language) },
<a ].map((item) => (
key={item.key} <a
href={ key={item.key}
item.key === 'GitHub' href={
? 'https://github.com/tinkle-community/nofx' item.key === 'GitHub'
: item.key === 'community' ? 'https://github.com/tinkle-community/nofx'
? 'https://t.me/nofx_dev_community' : item.key === 'community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}` ? '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} target={
className='block text-sm py-2' item.key === 'GitHub' || item.key === 'community'
style={{ color: 'var(--brand-light-gray)' }} ? '_blank'
> : undefined
{item.label} }
</a> 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 */} {/* Language Toggle */}
<div className='py-2'> <div className="py-2">
<div className='flex items-center gap-2 mb-2'> <div className="flex items-center gap-2 mb-2">
<span className='text-xs' style={{ color: 'var(--brand-light-gray)' }}>{t('language', language)}:</span> <span
className="text-xs"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('language', language)}:
</span>
</div> </div>
<div className='space-y-1'> <div className="space-y-1">
<button <button
onClick={() => { onClick={() => {
onLanguageChange?.('zh') onLanguageChange?.('zh')
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${ 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-lg">🇨🇳</span>
<span className='text-sm'></span> <span className="text-sm"></span>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -550,25 +718,49 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${ 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-lg">🇺🇸</span>
<span className='text-sm'>English</span> <span className="text-sm">English</span>
</button> </button>
</div> </div>
</div> </div>
{/* User info and logout for mobile when logged in */} {/* User info and logout for mobile when logged in */}
{isLoggedIn && user && ( {isLoggedIn && user && (
<div className='mt-4 pt-4' style={{ borderTop: '1px solid var(--panel-border)' }}> <div
<div className='flex items-center gap-2 px-3 py-2 mb-2 rounded' style={{ background: 'var(--panel-bg)' }}> className="mt-4 pt-4"
<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)' }}> 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()} {user.email[0].toUpperCase()}
</div> </div>
<div> <div>
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div> <div
<div className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</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>
</div> </div>
{!isAdminMode && onLogout && ( {!isAdminMode && onLogout && (
@@ -577,8 +769,11 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout() onLogout()
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className='w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center' 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)' }} style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
> >
{t('exitLogin', language)} {t('exitLogin', language)}
</button> </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 */} {/* Show login/register buttons when not logged in and not on login/register pages */}
{!isLoggedIn && currentPage !== 'login' && currentPage !== 'register' && ( {!isLoggedIn &&
<div className='space-y-2 mt-2'> currentPage !== 'login' &&
<a currentPage !== 'register' && (
href='/login' <div className="space-y-2 mt-2">
className='block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors' <a
style={{ color: 'var(--brand-light-gray)', border: '1px solid var(--brand-light-gray)' }} href="/login"
onClick={() => setMobileMenuOpen(false)} className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
> style={{
{t('signIn', language)} color: 'var(--brand-light-gray)',
</a> border: '1px solid var(--brand-light-gray)',
<a }}
href='/register' onClick={() => setMobileMenuOpen(false)}
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)' }} {t('signIn', language)}
onClick={() => setMobileMenuOpen(false)} </a>
> <a
{t('signUp', language)} href="/register"
</a> className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
</div> style={{
)} background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
</div>
)}
</div> </div>
</motion.div> </motion.div>
</nav> </nav>
) )
} }

View File

@@ -1,6 +1,8 @@
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion' import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { t, Language } from '../../i18n/translations' import { t, Language } from '../../i18n/translations'
import { useGitHubStats } from '../../hooks/useGitHubStats'
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
interface HeroSectionProps { interface HeroSectionProps {
language: Language language: Language
@@ -11,75 +13,151 @@ export default function HeroSection({ language }: HeroSectionProps) {
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]) const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8]) const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
const handControls = useAnimation() const handControls = useAnimation()
const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx')
// 动画数字 - 仅对 stars 添加动画
const animatedStars = useCounterAnimation({
start: 0,
end: stars,
duration: 2000,
})
const fadeInUp = { const fadeInUp = {
initial: { opacity: 0, y: 60 }, initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 }, animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] }, 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 ( return (
<section className='relative pt-32 pb-20 px-4'> <section className="relative pt-32 pb-20 px-4">
<div className='max-w-7xl mx-auto'> <div className="max-w-7xl mx-auto">
<div className='grid lg:grid-cols-2 gap-12 items-center'> <div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */} {/* 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 variants={fadeInUp}>
<motion.div <motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6' 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)' }} style={{
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }} 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)' }} /> <Sparkles
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}> className="w-4 h-4"
{t('githubStarsInDays', language)} 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>
K+ GitHub Stars
</>
) : (
<>
<span className="inline-block tabular-nums">
{(animatedStars / 1000).toFixed(1)}
</span>
K+ GitHub Stars in {daysOld} days
</>
)}
</span> </span>
</motion.div> </motion.div>
</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)} {t('heroTitle1', language)}
<br /> <br />
<span style={{ color: 'var(--brand-yellow)' }}>{t('heroTitle2', language)}</span> <span style={{ color: 'var(--brand-yellow)' }}>
{t('heroTitle2', language)}
</span>
</h1> </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)} {t('heroDescription', language)}
</motion.p> </motion.p>
<div className='flex items-center gap-3 flex-wrap'> <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 }}> <motion.a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img <img
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A' 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' alt="GitHub Stars"
className='h-7' className="h-7"
/> />
</motion.a> </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 <img
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A' 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' alt="GitHub Forks"
className='h-7' className="h-7"
/> />
</motion.a> </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 <img
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A' 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' alt="GitHub Contributors"
className='h-7' className="h-7"
/> />
</motion.a> </motion.a>
</div> </div>
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}> <motion.p
{t('poweredBy', language)} className="text-xs pt-4"
style={{ color: 'var(--text-tertiary)' }}
variants={fadeInUp}
>
{t('poweredBy', language)}
</motion.p> </motion.p>
</motion.div> </motion.div>
{/* Right Visual - Interactive Robot */} {/* Right Visual - Interactive Robot */}
<div <div
className='relative w-full cursor-pointer' className="relative w-full cursor-pointer"
onMouseEnter={() => { onMouseEnter={() => {
handControls.start({ handControls.start({
y: [-8, 8, -8], y: [-8, 8, -8],
@@ -88,9 +166,9 @@ export default function HeroSection({ language }: HeroSectionProps) {
transition: { transition: {
duration: 2.5, duration: 2.5,
repeat: Infinity, repeat: Infinity,
ease: "easeInOut", ease: 'easeInOut',
times: [0, 0.5, 1] times: [0, 0.5, 1],
} },
}) })
}} }}
onMouseLeave={() => { onMouseLeave={() => {
@@ -100,32 +178,32 @@ export default function HeroSection({ language }: HeroSectionProps) {
x: 0, x: 0,
transition: { transition: {
duration: 0.6, duration: 0.6,
ease: "easeOut" ease: 'easeOut',
} },
}) })
}} }}
> >
{/* Background Layer */} {/* Background Layer */}
<motion.img <motion.img
src='/images/hand-bg.png' src="/images/hand-bg.png"
alt='NOFX Platform Background' alt="NOFX Platform Background"
className='w-full opacity-90' className="w-full opacity-90"
style={{ opacity, scale }} style={{ opacity, scale }}
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 300 }} transition={{ type: 'spring', stiffness: 300 }}
/> />
{/* Hand Layer - Animated */} {/* Hand Layer - Animated */}
<motion.img <motion.img
src='/images/hand.png' src="/images/hand.png"
alt='Robot Hand' alt="Robot Hand"
className='absolute top-0 left-0 w-full' className="absolute top-0 left-0 w-full"
style={{ opacity }} style={{ opacity }}
animate={handControls} animate={handControls}
initial={{ y: 0, rotate: 0, x: 0 }} initial={{ y: 0, rotate: 0, x: 0 }}
whileHover={{ whileHover={{
scale: 1.05, scale: 1.05,
transition: { type: 'spring', stiffness: 400 } transition: { type: 'spring', stiffness: 400 },
}} }}
/> />
</div> </div>
@@ -134,4 +212,3 @@ export default function HeroSection({ language }: HeroSectionProps) {
</section> </section>
) )
} }

View File

@@ -4,20 +4,36 @@ import { t, Language } from '../../i18n/translations'
function StepCard({ number, title, description, delay }: any) { function StepCard({ number, title, description, delay }: any) {
return ( 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 <motion.div
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl' 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)' }} style={{
background: 'var(--binance-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{ scale: 1.2, rotate: 360 }} whileHover={{ scale: 1.2, rotate: 360 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }} transition={{ type: 'spring', stiffness: 260, damping: 20 }}
> >
{number} {number}
</motion.div> </motion.div>
<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} {title}
</h3> </h3>
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}> <p
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description} {description}
</p> </p>
</div> </div>
@@ -29,46 +45,91 @@ interface HowItWorksSectionProps {
language: Language language: Language
} }
export default function HowItWorksSection({ language }: HowItWorksSectionProps) { export default function HowItWorksSection({
language,
}: HowItWorksSectionProps) {
return ( return (
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'> <AnimatedSection id="how-it-works" backgroundColor="var(--brand-dark-gray)">
<div className='max-w-7xl mx-auto'> <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
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}> 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)} {t('howToStart', language)}
</h2> </h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}> <p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('fourSimpleSteps', language)} {t('fourSimpleSteps', language)}
</p> </p>
</motion.div> </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: 1,
{ number: 3, title: t('step3Title', language), description: t('step3Desc', language) }, title: t('step1Title', language),
{ number: 4, title: t('step4Title', language), description: t('step4Desc', 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) => ( ].map((step, index) => (
<StepCard key={step.number} {...step} delay={index * 0.1} /> <StepCard key={step.number} {...step} delay={index * 0.1} />
))} ))}
</div> </div>
<motion.div <motion.div
className='mt-12 p-6 rounded-xl flex items-start gap-4' 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)' }} style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }} whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }} viewport={{ once: true }}
whileHover={{ scale: 1.02 }} 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' }}> <div
<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> 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> <div>
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}> <div className="font-semibold mb-2" style={{ color: '#F6465D' }}>
{t('importantRiskWarning', language)} {t('importantRiskWarning', language)}
</div> </div>
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('riskWarningText', language)} {t('riskWarningText', language)}
</p> </p>
</div> </div>

View File

@@ -10,7 +10,7 @@ interface LoginModalProps {
export default function LoginModal({ onClose, language }: LoginModalProps) { export default function LoginModal({ onClose, language }: LoginModalProps) {
return ( return (
<motion.div <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)' }} style={{ background: 'rgba(0, 0, 0, 0.8)' }}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -18,32 +18,50 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
className='relative max-w-md w-full rounded-2xl p-8' 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)' }} style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
initial={{ scale: 0.9, y: 50 }} initial={{ scale: 0.9, y: 50 }}
animate={{ scale: 1, y: 0 }} animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 50 }} exit={{ scale: 0.9, y: 50 }}
onClick={(e) => e.stopPropagation()} 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 }}> <motion.button
<X className='w-6 h-6' /> 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> </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)} {t('accessNofxPlatform', language)}
</h2> </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)} {t('loginRegisterPrompt', language)}
</p> </p>
<div className='space-y-3'> <div className="space-y-3">
<motion.button <motion.button
onClick={() => { onClick={() => {
window.history.pushState({}, '', '/login') window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate')) window.dispatchEvent(new PopStateEvent('popstate'))
onClose() onClose()
}} }}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center' className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} style={{
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }} 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 }} whileTap={{ scale: 0.95 }}
> >
{t('signIn', language)} {t('signIn', language)}
@@ -54,8 +72,12 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
window.dispatchEvent(new PopStateEvent('popstate')) window.dispatchEvent(new PopStateEvent('popstate'))
onClose() onClose()
}} }}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center' 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)' }} 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)' }} whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
@@ -66,4 +88,3 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
</motion.div> </motion.div>
) )
} }

View File

@@ -1,62 +1,86 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'; import { getSystemConfig } from '../lib/config'
interface User { interface User {
id: string; id: string
email: string; email: string
} }
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null
token: string | null; token: string | null
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>; login: (
register: (email: string, password: string, betaCode?: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>; email: string,
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>; password: string
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>; ) => Promise<{
logout: () => void; success: boolean
isLoading: 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 }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
// 先检查是否为管理员模式(使用带缓存的系统配置获取) // 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig() getSystemConfig()
.then(data => { .then((data) => {
if (data.admin_mode) { if (data.admin_mode) {
// 管理员模式下模拟admin用户 // 管理员模式下模拟admin用户
setUser({ id: 'admin', email: 'admin@localhost' }); setUser({ id: 'admin', email: 'admin@localhost' })
setToken('admin-mode'); setToken('admin-mode')
} else { } else {
// 非管理员模式检查本地存储中是否有token // 非管理员模式检查本地存储中是否有token
const savedToken = localStorage.getItem('auth_token'); const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user'); const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) { if (savedToken && savedUser) {
setToken(savedToken); setToken(savedToken)
setUser(JSON.parse(savedUser)); setUser(JSON.parse(savedUser))
} }
} }
setIsLoading(false); setIsLoading(false)
}) })
.catch(err => { .catch((err) => {
console.error('Failed to fetch system config:', err); console.error('Failed to fetch system config:', err)
// 发生错误时,继续检查本地存储 // 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token'); const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user'); const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) { if (savedToken && savedUser) {
setToken(savedToken); setToken(savedToken)
setUser(JSON.parse(savedUser)); setUser(JSON.parse(savedUser))
} }
setIsLoading(false); setIsLoading(false)
}); })
}, []); }, [])
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
try { try {
@@ -66,9 +90,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); })
const data = await response.json(); const data = await response.json()
if (response.ok) { if (response.ok) {
if (data.requires_otp) { if (data.requires_otp) {
@@ -77,23 +101,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
userID: data.user_id, userID: data.user_id,
requiresOTP: true, requiresOTP: true,
message: data.message, message: data.message,
}; }
} }
} else { } else {
return { success: false, message: data.error }; return { success: false, message: data.error }
} }
} catch (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 { 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) { if (betaCode) {
requestBody.beta_code = betaCode; requestBody.beta_code = betaCode
} }
const response = await fetch('/api/register', { const response = await fetch('/api/register', {
@@ -102,9 +134,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); })
const data = await response.json(); const data = await response.json()
if (response.ok) { if (response.ok) {
return { return {
@@ -113,14 +145,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
otpSecret: data.otp_secret, otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url, qrCodeURL: data.qr_code_url,
message: data.message, message: data.message,
}; }
} else { } else {
return { success: false, message: data.error }; return { success: false, message: data.error }
} }
} catch (error) { } catch (error) {
return { success: false, message: '注册失败,请重试' }; return { success: false, message: '注册失败,请重试' }
} }
}; }
const verifyOTP = async (userID: string, otpCode: string) => { const verifyOTP = async (userID: string, otpCode: string) => {
try { try {
@@ -189,11 +221,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
const logout = () => { const logout = () => {
setUser(null); setUser(null)
setToken(null); setToken(null)
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user')
}; }
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -210,13 +242,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); )
} }
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext)
if (context === undefined) { 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
} }

View File

@@ -1,37 +1,41 @@
import { createContext, useContext, useState, ReactNode } from 'react'; import { createContext, useContext, useState, ReactNode } from 'react'
import type { Language } from '../i18n/translations'; import type { Language } from '../i18n/translations'
interface LanguageContextType { interface LanguageContextType {
language: Language; language: Language
setLanguage: (lang: Language) => void; setLanguage: (lang: Language) => void
} }
const LanguageContext = createContext<LanguageContextType | undefined>(undefined); const LanguageContext = createContext<LanguageContextType | undefined>(
undefined
)
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
// Initialize language from localStorage or default to English // Initialize language from localStorage or default to English
const [language, setLanguage] = useState<Language>(() => { const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('language'); const saved = localStorage.getItem('language')
return (saved === 'en' || saved === 'zh') ? saved : 'en'; return saved === 'en' || saved === 'zh' ? saved : 'en'
}); })
// Save language to localStorage whenever it changes // Save language to localStorage whenever it changes
const handleSetLanguage = (lang: Language) => { const handleSetLanguage = (lang: Language) => {
setLanguage(lang); setLanguage(lang)
localStorage.setItem('language', lang); localStorage.setItem('language', lang)
}; }
return ( return (
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage }}> <LanguageContext.Provider
value={{ language, setLanguage: handleSetLanguage }}
>
{children} {children}
</LanguageContext.Provider> </LanguageContext.Provider>
); )
} }
export function useLanguage() { export function useLanguage() {
const context = useContext(LanguageContext); const context = useContext(LanguageContext)
if (!context) { if (!context) {
throw new Error('useLanguage must be used within LanguageProvider'); throw new Error('useLanguage must be used within LanguageProvider')
} }
return context; return context
} }

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
interface UseCounterAnimationOptions {
start?: number
end: number
duration?: number
decimals?: number
}
export function useCounterAnimation({
start = 0,
end,
duration = 2000,
decimals = 0,
}: UseCounterAnimationOptions): number {
const [count, setCount] = useState(start)
useEffect(() => {
if (end === 0) return
let startTime: number | null = null
let animationFrame: number
const animate = (currentTime: number) => {
if (startTime === null) startTime = currentTime
const progress = Math.min((currentTime - startTime) / duration, 1)
// 使用 easeOutExpo 缓动函数,让数字快速启动后缓慢停止
const easeOutExpo = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
const currentCount = start + (end - start) * easeOutExpo
setCount(currentCount)
if (progress < 1) {
animationFrame = requestAnimationFrame(animate)
} else {
setCount(end)
}
}
animationFrame = requestAnimationFrame(animate)
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
}
}, [start, end, duration])
return decimals > 0 ? parseFloat(count.toFixed(decimals)) : Math.floor(count)
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
interface GitHubStats {
stars: number
forks: number
createdAt: string
daysOld: number
isLoading: boolean
error: string | null
}
export function useGitHubStats(owner: string, repo: string): GitHubStats {
const [stats, setStats] = useState<GitHubStats>({
stars: 0,
forks: 0,
createdAt: '',
daysOld: 0,
isLoading: true,
error: null,
})
useEffect(() => {
const fetchGitHubStats = async () => {
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`
)
if (!response.ok) {
throw new Error('Failed to fetch GitHub stats')
}
const data = await response.json()
// Calculate days since creation
const createdDate = new Date(data.created_at)
const now = new Date()
const diffTime = Math.abs(now.getTime() - createdDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
setStats({
stars: data.stargazers_count,
forks: data.forks_count,
createdAt: data.created_at,
daysOld: diffDays,
isLoading: false,
error: null,
})
} catch (error) {
console.error('Error fetching GitHub stats:', error)
setStats((prev) => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}))
}
}
fetchGitHubStats()
}, [owner, repo])
return stats
}

View File

@@ -1,29 +1,29 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react'
import { getSystemConfig, type SystemConfig } from '../lib/config'; import { getSystemConfig, type SystemConfig } from '../lib/config'
export function useSystemConfig() { export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null); const [config, setConfig] = useState<SystemConfig | null>(null)
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true
getSystemConfig() getSystemConfig()
.then((data) => { .then((data) => {
if (!mounted) return; if (!mounted) return
setConfig(data); setConfig(data)
setLoading(false); setLoading(false)
}) })
.catch((err: Error) => { .catch((err: Error) => {
if (!mounted) return; if (!mounted) return
console.error('Failed to fetch system config:', err); console.error('Failed to fetch system config:', err)
setError(err.message); setError(err.message)
setLoading(false); setLoading(false)
}); })
return () => { return () => {
mounted = false; mounted = false
}; }
}, []); }, [])
return { config, loading, error }; return { config, loading, error }
} }

View File

@@ -1,4 +1,4 @@
export type Language = 'en' | 'zh'; export type Language = 'en' | 'zh'
export const translations = { export const translations = {
en: { en: {
@@ -15,7 +15,7 @@ export const translations = {
logout: 'Logout', logout: 'Logout',
switchTrader: 'Switch Trader:', switchTrader: 'Switch Trader:',
view: 'View', view: 'View',
// Navigation // Navigation
realtimeNav: 'Live', realtimeNav: 'Live',
configNav: 'Config', configNav: 'Config',
@@ -74,7 +74,7 @@ export const translations = {
recent: 'Recent', recent: 'Recent',
allData: 'All Data', allData: 'All Data',
cycles: 'Cycles', cycles: 'Cycles',
// Comparison Chart // Comparison Chart
comparisonMode: 'Comparison Mode', comparisonMode: 'Comparison Mode',
dataPoints: 'Data Points', dataPoints: 'Data Points',
@@ -147,7 +147,8 @@ export const translations = {
createFirstTrader: 'Create your first AI trader to get started', createFirstTrader: 'Create your first AI trader to get started',
configureModelsFirst: 'Please configure AI models first', configureModelsFirst: 'Please configure AI models first',
configureExchangesFirst: 'Please configure exchanges 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', modelNotConfigured: 'Selected model is not configured',
exchangeNotConfigured: 'Selected exchange is not configured', exchangeNotConfigured: 'Selected exchange is not configured',
confirmDeleteTrader: 'Are you sure you want to delete this trader?', confirmDeleteTrader: 'Are you sure you want to delete this trader?',
@@ -168,7 +169,7 @@ export const translations = {
useTestnet: 'Use Testnet', useTestnet: 'Use Testnet',
enabled: 'Enabled', enabled: 'Enabled',
save: 'Save', save: 'Save',
// AI Model Configuration // AI Model Configuration
officialAPI: 'Official API', officialAPI: 'Official API',
customAPI: 'Custom API', customAPI: 'Custom API',
@@ -192,9 +193,12 @@ export const translations = {
enterSigner: 'Enter Signer Address', enterSigner: 'Enter Signer Address',
enterSecretKey: 'Enter Secret Key', enterSecretKey: 'Enter Secret Key',
enterPassphrase: 'Enter Passphrase (Required for OKX)', enterPassphrase: 'Enter Passphrase (Required for OKX)',
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication', hyperliquidPrivateKeyDesc:
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key', 'Hyperliquid uses private key for trading authentication',
testnetDescription: 'Enable to connect to exchange test environment for simulated trading', hyperliquidWalletAddressDesc:
'Wallet address corresponding to the private key',
testnetDescription:
'Enable to connect to exchange test environment for simulated trading',
securityWarning: 'Security Warning', securityWarning: 'Security Warning',
saveConfiguration: 'Save Configuration', saveConfiguration: 'Save Configuration',
@@ -202,20 +206,25 @@ export const translations = {
positionMode: 'Position Mode', positionMode: 'Position Mode',
crossMarginMode: 'Cross Margin', crossMarginMode: 'Cross Margin',
isolatedMarginMode: 'Isolated Margin', isolatedMarginMode: 'Isolated Margin',
crossMarginDescription: 'Cross margin: All positions share account balance as collateral', crossMarginDescription:
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation', 'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription:
'Isolated margin: Each position manages collateral independently, risk isolation',
leverageConfiguration: 'Leverage Configuration', leverageConfiguration: 'Leverage Configuration',
btcEthLeverage: 'BTC/ETH Leverage', btcEthLeverage: 'BTC/ETH Leverage',
altcoinLeverage: 'Altcoin 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', 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', selectSymbols: 'Select Symbols',
selectTradingSymbols: 'Select Trading Symbols', selectTradingSymbols: 'Select Trading Symbols',
selectedSymbolsCount: 'Selected {count} symbols', selectedSymbolsCount: 'Selected {count} symbols',
clearSelection: 'Clear All', clearSelection: 'Clear All',
confirmSelection: 'Confirm', 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', btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x', altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT', invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
@@ -223,7 +232,8 @@ export const translations = {
// Loading & Error // Loading & Error
loading: 'Loading...', loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data', 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 // AI Traders Page - Additional
inUse: 'In Use', inUse: 'In Use',
@@ -231,31 +241,44 @@ export const translations = {
noExchangesConfigured: 'No configured exchanges', noExchangesConfigured: 'No configured exchanges',
signalSource: 'Signal Source', signalSource: 'Signal Source',
signalSourceConfig: 'Signal Source Configuration', signalSourceConfig: 'Signal Source Configuration',
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source', coinPoolDescription:
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source', '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', information: 'Information',
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs', signalSourceInfo1:
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources', '• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals', 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', editAIModel: 'Edit AI Model',
addAIModel: 'Add 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', selectModel: 'Select AI Model',
pleaseSelectModel: 'Please select a model', pleaseSelectModel: 'Please select a model',
customBaseURL: 'Base URL (Optional)', 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', 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', 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', saveConfig: 'Save Configuration',
editExchange: 'Edit Exchange', editExchange: 'Edit Exchange',
addExchange: 'Add 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', pleaseSelectExchange: 'Please select an exchange',
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions', exchangeConfigWarning1:
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security', '• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade', exchangeConfigWarning2:
'• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3:
'• After deleting configuration, related traders will not be able to trade',
edit: 'Edit', edit: 'Edit',
viewGuide: 'View Guide', viewGuide: 'View Guide',
binanceSetupGuide: 'Binance Setup Guide', binanceSetupGuide: 'Binance Setup Guide',
@@ -265,7 +288,8 @@ export const translations = {
createTraderFailed: 'Failed to create trader', createTraderFailed: 'Failed to create trader',
getTraderConfigFailed: 'Failed to get trader configuration', getTraderConfigFailed: 'Failed to get trader configuration',
modelConfigNotExist: 'Model configuration does not exist or is not enabled', 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', updateTraderFailed: 'Failed to update trader',
deleteTraderFailed: 'Failed to delete trader', deleteTraderFailed: 'Failed to delete trader',
operationFailed: 'Operation failed', operationFailed: 'Operation failed',
@@ -275,7 +299,7 @@ export const translations = {
exchangeNotExist: 'Exchange does not exist', exchangeNotExist: 'Exchange does not exist',
deleteExchangeConfigFailed: 'Failed to delete exchange configuration', deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
saveSignalSourceFailed: 'Failed to save signal source configuration', saveSignalSourceFailed: 'Failed to save signal source configuration',
// Login & Register // Login & Register
login: 'Sign In', login: 'Sign In',
register: 'Sign Up', register: 'Sign Up',
@@ -302,12 +326,15 @@ export const translations = {
enterOTPCode: 'Enter 6-digit OTP code', enterOTPCode: 'Enter 6-digit OTP code',
verifyOTP: 'Verify OTP', verifyOTP: 'Verify OTP',
setupTwoFactor: 'Set up two-factor authentication', setupTwoFactor: 'Set up two-factor authentication',
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator', setupTwoFactorDesc:
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy', '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:', otpSecret: 'Or enter this secret manually:',
qrCodeHint: 'QR code (if scanning fails, use the secret below):', qrCodeHint: 'QR code (if scanning fails, use the secret below):',
authStep1Title: 'Step 1: Install Google Authenticator', 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', authStep2Title: 'Step 2: Add account',
authStep2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"', authStep2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
authStep3Title: 'Step 3: Verify setup', authStep3Title: 'Step 3: Verify setup',
@@ -337,74 +364,93 @@ export const translations = {
exitLogin: 'Sign Out', exitLogin: 'Sign Out',
signIn: 'Sign In', signIn: 'Sign In',
signUp: 'Sign Up', signUp: 'Sign Up',
// Hero Section // Hero Section
githubStarsInDays: '2.5K+ GitHub Stars in 3 days', githubStarsInDays: '2.5K+ GitHub Stars in 3 days',
heroTitle1: 'Read the Market.', heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.', 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.', heroDescription:
poweredBy: 'Powered by Aster DEX and Binance, strategically invested by Amber.ac.', '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 // Landing Page CTA
readyToDefine: 'Ready to define the future of AI trading?', 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', getStartedNow: 'Get Started Now',
viewSourceCode: 'View Source Code', viewSourceCode: 'View Source Code',
// Features Section // Features Section
coreFeatures: 'Core Features', coreFeatures: 'Core Features',
whyChooseNofx: 'Why Choose NOFX?', 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', 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', openSourceFeatures1: 'Fully open source code',
openSourceFeatures2: 'Self-hosting deployment support', openSourceFeatures2: 'Self-hosting deployment support',
openSourceFeatures3: 'Custom AI prompts', openSourceFeatures3: 'Custom AI prompts',
openSourceFeatures4: 'Multi-model support (DeepSeek, Qwen)', openSourceFeatures4: 'Multi-model support (DeepSeek, Qwen)',
multiAgentCompetition: 'Multi-Agent Intelligent Competition', 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', multiAgentFeatures1: 'Multiple AI agents running in parallel',
multiAgentFeatures2: 'Automatic strategy optimization', multiAgentFeatures2: 'Automatic strategy optimization',
multiAgentFeatures3: 'Sandbox security testing', multiAgentFeatures3: 'Sandbox security testing',
multiAgentFeatures4: 'Cross-market strategy porting', multiAgentFeatures4: 'Cross-market strategy porting',
secureReliableTrading: 'Secure and Reliable Trading', 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', secureFeatures1: 'Local private key management',
secureFeatures2: 'Fine-grained API permission control', secureFeatures2: 'Fine-grained API permission control',
secureFeatures3: 'Real-time risk monitoring', secureFeatures3: 'Real-time risk monitoring',
secureFeatures4: 'Trading log auditing', secureFeatures4: 'Trading log auditing',
// About Section // About Section
aboutNofx: 'About NOFX', aboutNofx: 'About NOFX',
whatIsNofx: 'What is NOFX?', whatIsNofx: 'What is NOFX?',
nofxNotAnotherBot: "NOFX is not another trading bot, but the 'Linux' of AI trading —", nofxNotAnotherBot:
nofxDescription1: 'a transparent, trustworthy open source OS that provides a unified', "NOFX is not another trading bot, but the 'Linux' of AI trading —",
nofxDescription2: "'decision-risk-execution' layer, supporting all asset classes.", nofxDescription1:
nofxDescription3: 'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI', 'a transparent, trustworthy open source OS that provides a unified',
nofxDescription4: 'Darwinism (multi-agent self-competition, strategy evolution), CodeFi', nofxDescription2:
nofxDescription5: 'flywheel (developers get point rewards for PR contributions).', "'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', youFullControl: 'You 100% Control',
fullControlDesc: 'Complete control over AI prompts and funds', fullControlDesc: 'Complete control over AI prompts and funds',
startupMessages1: 'Starting automated trading system...', startupMessages1: 'Starting automated trading system...',
startupMessages2: 'API server started on port 8080', startupMessages2: 'API server started on port 8080',
startupMessages3: 'Web console http://localhost:3000', startupMessages3: 'Web console http://localhost:3000',
// How It Works Section // How It Works Section
howToStart: 'How to Get Started with NOFX', 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', 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', 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', 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', 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', 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) // Community Section (testimonials are kept as-is since they are quotes)
// Footer Section // Footer Section
futureStandardAI: 'The future standard of AI trading', futureStandardAI: 'The future standard of AI trading',
links: 'Links', links: 'Links',
@@ -412,10 +458,11 @@ export const translations = {
documentation: 'Documentation', documentation: 'Documentation',
supporters: 'Supporters', supporters: 'Supporters',
strategicInvestment: '(Strategic Investment)', strategicInvestment: '(Strategic Investment)',
// Login Modal // Login Modal
accessNofxPlatform: 'Access NOFX Platform', 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', registerNewAccount: 'Register New Account',
}, },
zh: { zh: {
@@ -432,7 +479,7 @@ export const translations = {
logout: '退出', logout: '退出',
switchTrader: '切换交易员:', switchTrader: '切换交易员:',
view: '查看', view: '查看',
// Navigation // Navigation
realtimeNav: '实时', realtimeNav: '实时',
configNav: '配置', configNav: '配置',
@@ -491,7 +538,7 @@ export const translations = {
recent: '最近', recent: '最近',
allData: '全部数据', allData: '全部数据',
cycles: '个', cycles: '个',
// Comparison Chart // Comparison Chart
comparisonMode: '对比模式', comparisonMode: '对比模式',
dataPoints: '数据点数', dataPoints: '数据点数',
@@ -585,7 +632,7 @@ export const translations = {
useTestnet: '使用测试网', useTestnet: '使用测试网',
enabled: '启用', enabled: '启用',
save: '保存', save: '保存',
// AI Model Configuration // AI Model Configuration
officialAPI: '官方API', officialAPI: '官方API',
customAPI: '自定义API', customAPI: '自定义API',
@@ -626,13 +673,15 @@ export const translations = {
altcoinLeverage: '山寨币杠杆', altcoinLeverage: '山寨币杠杆',
leverageRecommendation: '推荐BTC/ETH 5-10倍山寨币 3-5倍控制风险', leverageRecommendation: '推荐BTC/ETH 5-10倍山寨币 3-5倍控制风险',
tradingSymbols: '交易币种', tradingSymbols: '交易币种',
tradingSymbolsPlaceholder: '输入币种逗号分隔BTCUSDT,ETHUSDT,SOLUSDT', tradingSymbolsPlaceholder:
'输入币种逗号分隔BTCUSDT,ETHUSDT,SOLUSDT',
selectSymbols: '选择币种', selectSymbols: '选择币种',
selectTradingSymbols: '选择交易币种', selectTradingSymbols: '选择交易币种',
selectedSymbolsCount: '已选择 {count} 个币种', selectedSymbolsCount: '已选择 {count} 个币种',
clearSelection: '清空选择', clearSelection: '清空选择',
confirmSelection: '确认选择', confirmSelection: '确认选择',
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾BTCUSDT, ETHUSDT', tradingSymbolsDescription:
'留空 = 使用默认币种。必须以USDT结尾BTCUSDT, ETHUSDT',
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间', btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间', altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
invalidSymbolFormat: '无效的币种格式:{symbol}必须以USDT结尾', invalidSymbolFormat: '无效的币种格式:{symbol}必须以USDT结尾',
@@ -651,7 +700,8 @@ export const translations = {
coinPoolDescription: '用于获取币种池数据的API地址留空则不使用此信号源', coinPoolDescription: '用于获取币种池数据的API地址留空则不使用此信号源',
oiTopDescription: '用于获取持仓量排行数据的API地址留空则不使用此信号源', oiTopDescription: '用于获取持仓量排行数据的API地址留空则不使用此信号源',
information: '说明', information: '说明',
signalSourceInfo1: '• 信号源配置为用户级别每个用户可以设置自己的信号源URL', signalSourceInfo1:
'• 信号源配置为用户级别每个用户可以设置自己的信号源URL',
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源', signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号', signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
editAIModel: '编辑AI模型', editAIModel: '编辑AI模型',
@@ -692,7 +742,7 @@ export const translations = {
exchangeNotExist: '交易所不存在', exchangeNotExist: '交易所不存在',
deleteExchangeConfigFailed: '删除交易所配置失败', deleteExchangeConfigFailed: '删除交易所配置失败',
saveSignalSourceFailed: '保存信号源配置失败', saveSignalSourceFailed: '保存信号源配置失败',
// Login & Register // Login & Register
login: '登录', login: '登录',
register: '注册', register: '注册',
@@ -754,20 +804,22 @@ export const translations = {
exitLogin: '退出登录', exitLogin: '退出登录',
signIn: '登录', signIn: '登录',
signUp: '注册', signUp: '注册',
// Hero Section // Hero Section
githubStarsInDays: '3 天内 2.5K+ GitHub Stars', githubStarsInDays: '3 天内 2.5K+ GitHub Stars',
heroTitle1: 'Read the Market.', heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.', heroTitle2: 'Write the Trade.',
heroDescription: 'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。', heroDescription:
'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
poweredBy: '由 Aster DEX 和 Binance 提供支持Amber.ac 战略投资。', poweredBy: '由 Aster DEX 和 Binance 提供支持Amber.ac 战略投资。',
// Landing Page CTA // Landing Page CTA
readyToDefine: '准备好定义 AI 交易的未来吗?', readyToDefine: '准备好定义 AI 交易的未来吗?',
startWithCrypto: '从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。', startWithCrypto:
'从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
getStartedNow: '立即开始', getStartedNow: '立即开始',
viewSourceCode: '查看源码', viewSourceCode: '查看源码',
// Features Section // Features Section
coreFeatures: '核心功能', coreFeatures: '核心功能',
whyChooseNofx: '为什么选择 NOFX', whyChooseNofx: '为什么选择 NOFX',
@@ -790,38 +842,44 @@ export const translations = {
secureFeatures2: 'API 权限精细控制', secureFeatures2: 'API 权限精细控制',
secureFeatures3: '实时风险监控', secureFeatures3: '实时风险监控',
secureFeatures4: '交易日志审计', secureFeatures4: '交易日志审计',
// About Section // About Section
aboutNofx: '关于 NOFX', aboutNofx: '关于 NOFX',
whatIsNofx: '什么是 NOFX', whatIsNofx: '什么是 NOFX',
nofxNotAnotherBot: 'NOFX 不是另一个交易机器人,而是 AI 交易的 \'Linux\' ——', nofxNotAnotherBot: "NOFX 不是另一个交易机器人,而是 AI 交易的 'Linux' ——",
nofxDescription1: '一个透明、可信任的开源 OS提供统一的 \'决策-风险-执行\'', nofxDescription1: "一个透明、可信任的开源 OS提供统一的 '决策-风险-执行'",
nofxDescription2: '层,支持所有资产类别。', nofxDescription2: '层,支持所有资产类别。',
nofxDescription3: '从加密市场起步24/7、高波动性完美测试场未来扩展到股票、期货、外汇。核心开放架构、AI', nofxDescription3:
nofxDescription4: '达尔文主义多代理自竞争、策略进化、CodeFi 飞轮(开发者 PR', '从加密市场起步24/7、高波动性完美测试场未来扩展到股票、期货、外汇。核心开放架构、AI',
nofxDescription4:
'达尔文主义多代理自竞争、策略进化、CodeFi 飞轮(开发者 PR',
nofxDescription5: '贡献获积分奖励)。', nofxDescription5: '贡献获积分奖励)。',
youFullControl: '你 100% 掌控', youFullControl: '你 100% 掌控',
fullControlDesc: '完全掌控 AI 提示词和资金', fullControlDesc: '完全掌控 AI 提示词和资金',
startupMessages1: ' 启动自动交易系统...', startupMessages1: '启动自动交易系统...',
startupMessages2: ' API服务器启动在端口 8080', startupMessages2: 'API服务器启动在端口 8080',
startupMessages3: ' Web 控制台 http://localhost:3000', startupMessages3: 'Web 控制台 http://localhost:3000',
// How It Works Section // How It Works Section
howToStart: '如何开始使用 NOFX', howToStart: '如何开始使用 NOFX',
fourSimpleSteps: '四个简单步骤,开启 AI 自动交易之旅', fourSimpleSteps: '四个简单步骤,开启 AI 自动交易之旅',
step1Title: '拉取 GitHub 仓库', step1Title: '拉取 GitHub 仓库',
step1Desc: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。', step1Desc:
'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
step2Title: '配置环境', step2Title: '配置环境',
step2Desc: '前端设置交易所 API如 Binance、Hyperliquid、AI 模型和自定义提示词。', step2Desc:
'前端设置交易所 API如 Binance、Hyperliquid、AI 模型和自定义提示词。',
step3Title: '部署与运行', step3Title: '部署与运行',
step3Desc: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。', step3Desc:
'一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
step4Title: '优化与贡献', step4Title: '优化与贡献',
step4Desc: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。', step4Desc: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。',
importantRiskWarning: '重要风险提示', importantRiskWarning: '重要风险提示',
riskWarningText: 'dev 分支不稳定勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。', riskWarningText:
'dev 分支不稳定勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
// Community Section (testimonials are kept as-is since they are quotes) // Community Section (testimonials are kept as-is since they are quotes)
// Footer Section // Footer Section
futureStandardAI: 'AI 交易的未来标准', futureStandardAI: 'AI 交易的未来标准',
links: '链接', links: '链接',
@@ -829,23 +887,27 @@ export const translations = {
documentation: '文档', documentation: '文档',
supporters: '支持方', supporters: '支持方',
strategicInvestment: '(战略投资)', strategicInvestment: '(战略投资)',
// Login Modal // Login Modal
accessNofxPlatform: '访问 NOFX 平台', accessNofxPlatform: '访问 NOFX 平台',
loginRegisterPrompt: '请选择登录或注册以访问完整的 AI 交易平台', loginRegisterPrompt: '请选择登录或注册以访问完整的 AI 交易平台',
registerNewAccount: '注册新账号', registerNewAccount: '注册新账号',
} },
}; }
export function t(key: string, lang: Language, params?: Record<string, string | number>): string { export function t(
let text = translations[lang][key as keyof typeof translations['en']] || key; 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. // Replace parameters like {count}, {gap}, etc.
if (params) { if (params) {
Object.entries(params).forEach(([param, value]) => { Object.entries(params).forEach(([param, value]) => {
text = text.replace(`{${param}}`, String(value)); text = text.replace(`{${param}}`, String(value))
}); })
} }
return text; return text
} }

View File

@@ -12,54 +12,62 @@ html {
:root { :root {
/* Binance Brand Colors */ /* Binance Brand Colors */
--brand-yellow: #F0B90B; --brand-yellow: #f0b90b;
--brand-black: #000000; --brand-black: #000000;
--brand-dark-gray: #0A0A0A; --brand-dark-gray: #0a0a0a;
--brand-light-gray: #EAECEF; --brand-light-gray: #eaecef;
--brand-almost-white: #FAFAFA; --brand-almost-white: #fafafa;
--brand-white: #FFFFFF; --brand-white: #ffffff;
/* Binance Theme Colors */ /* Binance Theme Colors */
--binance-yellow: #F0B90B; --binance-yellow: #f0b90b;
--binance-yellow-dark: #C99400; --binance-yellow-dark: #c99400;
--binance-yellow-light: #FCD535; --binance-yellow-light: #fcd535;
--binance-yellow-glow: rgba(240, 185, 11, 0.2); --binance-yellow-glow: rgba(240, 185, 11, 0.2);
--background: #000000; /* Binance body bg */ --background: #000000; /* Binance body bg */
--header-bg: #000000; /* Binance header bg */ --header-bg: #000000; /* Binance header bg */
--background-elevated: #000000; --background-elevated: #000000;
--foreground: #EAECEF; --foreground: #eaecef;
--panel-bg: #0A0A0A; --panel-bg: #0a0a0a;
--panel-bg-hover: #111111; --panel-bg-hover: #111111;
--panel-border: #1A1A1A; --panel-border: #1a1a1a;
--panel-border-hover: #2A2A2A; --panel-border-hover: #2a2a2a;
/* Binance Signature Colors */ /* Binance Signature Colors */
--binance-green: #0ECB81; --binance-green: #0ecb81;
--binance-green-bg: rgba(14, 203, 129, 0.1); --binance-green-bg: rgba(14, 203, 129, 0.1);
--binance-green-border: rgba(14, 203, 129, 0.2); --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-bg: rgba(246, 70, 93, 0.1);
--binance-red-border: rgba(246, 70, 93, 0.2); --binance-red-border: rgba(246, 70, 93, 0.2);
/* UI Colors */ /* UI Colors */
--text-primary: #EAECEF; --text-primary: #eaecef;
--text-secondary: #848E9C; --text-secondary: #848e9c;
--text-tertiary: #5E6673; --text-tertiary: #5e6673;
--text-disabled: #474D57; --text-disabled: #474d57;
/* Chart Colors */ /* Chart Colors */
--grid-stroke: #1A1A1A; --grid-stroke: #1a1a1a;
--axis-tick: #5E6673; --axis-tick: #5e6673;
--ref-line: #474D57; --ref-line: #474d57;
/* Shadows */ /* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --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-md:
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3); 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px 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-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; line-height: 1.6;
font-weight: 400; font-weight: 400;
color-scheme: dark; color-scheme: dark;
@@ -69,7 +77,7 @@ html {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum"; font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -140,7 +148,8 @@ body {
} }
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
box-shadow: 0 0 8px currentColor; box-shadow: 0 0 8px currentColor;
} }
@@ -160,7 +169,8 @@ body {
} }
@keyframes pulse-scale { @keyframes pulse-scale {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
@@ -240,11 +250,19 @@ button:disabled {
/* Binance gradient backgrounds */ /* Binance gradient backgrounds */
.binance-gradient { .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 { .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); border: 1px solid rgba(240, 185, 11, 0.2);
} }
@@ -456,7 +474,12 @@ tr:hover {
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
background: linear-gradient(90deg, transparent, var(--binance-yellow), transparent); background: linear-gradient(
90deg,
transparent,
var(--binance-yellow),
transparent
);
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }

View File

@@ -11,22 +11,22 @@ import type {
UpdateModelConfigRequest, UpdateModelConfigRequest,
UpdateExchangeConfigRequest, UpdateExchangeConfigRequest,
CompetitionData, CompetitionData,
} from '../types'; } from '../types'
const API_BASE = '/api'; const API_BASE = '/api'
// Helper function to get auth headers // Helper function to get auth headers
function getAuthHeaders(): Record<string, string> { function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token')
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} }
return headers; if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
} }
export const api = { export const api = {
@@ -34,16 +34,16 @@ export const api = {
async getTraders(): Promise<TraderInfo[]> { async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/my-traders`, { const res = await fetch(`${API_BASE}/my-traders`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取trader列表失败'); if (!res.ok) throw new Error('获取trader列表失败')
return res.json(); return res.json()
}, },
// 获取公开的交易员列表(无需认证) // 获取公开的交易员列表(无需认证)
async getPublicTraders(): Promise<any[]> { async getPublicTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/traders`); const res = await fetch(`${API_BASE}/traders`)
if (!res.ok) throw new Error('获取公开trader列表失败'); if (!res.ok) throw new Error('获取公开trader列表失败')
return res.json(); return res.json()
}, },
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> { async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
@@ -51,76 +51,82 @@ export const api = {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); })
if (!res.ok) throw new Error('创建交易员失败'); if (!res.ok) throw new Error('创建交易员失败')
return res.json(); return res.json()
}, },
async deleteTrader(traderId: string): Promise<void> { async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, { const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('删除交易员失败'); if (!res.ok) throw new Error('删除交易员失败')
}, },
async startTrader(traderId: string): Promise<void> { async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('启动交易员失败'); if (!res.ok) throw new Error('启动交易员失败')
}, },
async stopTrader(traderId: string): Promise<void> { async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), 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`, { const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ custom_prompt: customPrompt }), body: JSON.stringify({ custom_prompt: customPrompt }),
}); })
if (!res.ok) throw new Error('更新自定义策略失败'); if (!res.ok) throw new Error('更新自定义策略失败')
}, },
async getTraderConfig(traderId: string): Promise<any> { async getTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, { const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取交易员配置失败'); if (!res.ok) throw new Error('获取交易员配置失败')
return res.json(); 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}`, { const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); })
if (!res.ok) throw new Error('更新交易员失败'); if (!res.ok) throw new Error('更新交易员失败')
return res.json(); return res.json()
}, },
// AI模型配置接口 // AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> { async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`, { const res = await fetch(`${API_BASE}/models`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取模型配置失败'); if (!res.ok) throw new Error('获取模型配置失败')
return res.json(); return res.json()
}, },
// 获取系统支持的AI模型列表无需认证 // 获取系统支持的AI模型列表无需认证
async getSupportedModels(): Promise<AIModel[]> { async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`); const res = await fetch(`${API_BASE}/supported-models`)
if (!res.ok) throw new Error('获取支持的模型失败'); if (!res.ok) throw new Error('获取支持的模型失败')
return res.json(); return res.json()
}, },
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> { async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
@@ -128,123 +134,125 @@ export const api = {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); })
if (!res.ok) throw new Error('更新模型配置失败'); if (!res.ok) throw new Error('更新模型配置失败')
}, },
// 交易所配置接口 // 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> { async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`, { const res = await fetch(`${API_BASE}/exchanges`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取交易所配置失败'); if (!res.ok) throw new Error('获取交易所配置失败')
return res.json(); return res.json()
}, },
// 获取系统支持的交易所列表(无需认证) // 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> { async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`); const res = await fetch(`${API_BASE}/supported-exchanges`)
if (!res.ok) throw new Error('获取支持的交易所失败'); if (!res.ok) throw new Error('获取支持的交易所失败')
return res.json(); return res.json()
}, },
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> { async updateExchangeConfigs(
request: UpdateExchangeConfigRequest
): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, { const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); })
if (!res.ok) throw new Error('更新交易所配置失败'); if (!res.ok) throw new Error('更新交易所配置失败')
}, },
// 获取系统状态支持trader_id // 获取系统状态支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> { async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId const url = traderId
? `${API_BASE}/status?trader_id=${traderId}` ? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`; : `${API_BASE}/status`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取系统状态失败'); if (!res.ok) throw new Error('获取系统状态失败')
return res.json(); return res.json()
}, },
// 获取账户信息支持trader_id // 获取账户信息支持trader_id
async getAccount(traderId?: string): Promise<AccountInfo> { async getAccount(traderId?: string): Promise<AccountInfo> {
const url = traderId const url = traderId
? `${API_BASE}/account?trader_id=${traderId}` ? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`; : `${API_BASE}/account`
const res = await fetch(url, { const res = await fetch(url, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
...getAuthHeaders(), ...getAuthHeaders(),
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
}, },
}); })
if (!res.ok) throw new Error('获取账户信息失败'); if (!res.ok) throw new Error('获取账户信息失败')
const data = await res.json(); const data = await res.json()
console.log('Account data fetched:', data); console.log('Account data fetched:', data)
return data; return data
}, },
// 获取持仓列表支持trader_id // 获取持仓列表支持trader_id
async getPositions(traderId?: string): Promise<Position[]> { async getPositions(traderId?: string): Promise<Position[]> {
const url = traderId const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}` ? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`; : `${API_BASE}/positions`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取持仓列表失败'); if (!res.ok) throw new Error('获取持仓列表失败')
return res.json(); return res.json()
}, },
// 获取决策日志支持trader_id // 获取决策日志支持trader_id
async getDecisions(traderId?: string): Promise<DecisionRecord[]> { async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}` ? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`; : `${API_BASE}/decisions`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取决策日志失败'); if (!res.ok) throw new Error('获取决策日志失败')
return res.json(); return res.json()
}, },
// 获取最新决策支持trader_id // 获取最新决策支持trader_id
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> { async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}` ? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`; : `${API_BASE}/decisions/latest`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取最新决策失败'); if (!res.ok) throw new Error('获取最新决策失败')
return res.json(); return res.json()
}, },
// 获取统计信息支持trader_id // 获取统计信息支持trader_id
async getStatistics(traderId?: string): Promise<Statistics> { async getStatistics(traderId?: string): Promise<Statistics> {
const url = traderId const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}` ? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`; : `${API_BASE}/statistics`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取统计信息失败'); if (!res.ok) throw new Error('获取统计信息失败')
return res.json(); return res.json()
}, },
// 获取收益率历史数据支持trader_id // 获取收益率历史数据支持trader_id
async getEquityHistory(traderId?: string): Promise<any[]> { async getEquityHistory(traderId?: string): Promise<any[]> {
const url = traderId const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}` ? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`; : `${API_BASE}/equity-history`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取历史数据失败'); if (!res.ok) throw new Error('获取历史数据失败')
return res.json(); return res.json()
}, },
// 批量获取多个交易员的历史数据(无需认证) // 批量获取多个交易员的历史数据(无需认证)
@@ -255,54 +263,60 @@ export const api = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ trader_ids: traderIds }), body: JSON.stringify({ trader_ids: traderIds }),
}); })
if (!res.ok) throw new Error('获取批量历史数据失败'); if (!res.ok) throw new Error('获取批量历史数据失败')
return res.json(); return res.json()
}, },
// 获取前5名交易员数据无需认证 // 获取前5名交易员数据无需认证
async getTopTraders(): Promise<any[]> { async getTopTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/top-traders`); const res = await fetch(`${API_BASE}/top-traders`)
if (!res.ok) throw new Error('获取前5名交易员失败'); if (!res.ok) throw new Error('获取前5名交易员失败')
return res.json(); return res.json()
}, },
// 获取公开交易员配置(无需认证) // 获取公开交易员配置(无需认证)
async getPublicTraderConfig(traderId: string): Promise<any> { async getPublicTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/trader/${traderId}/config`); const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
if (!res.ok) throw new Error('获取公开交易员配置失败'); if (!res.ok) throw new Error('获取公开交易员配置失败')
return res.json(); return res.json()
}, },
// 获取AI学习表现分析支持trader_id // 获取AI学习表现分析支持trader_id
async getPerformance(traderId?: string): Promise<any> { async getPerformance(traderId?: string): Promise<any> {
const url = traderId const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}` ? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`; : `${API_BASE}/performance`
const res = await fetch(url, { const res = await fetch(url, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取AI学习数据失败'); if (!res.ok) throw new Error('获取AI学习数据失败')
return res.json(); return res.json()
}, },
// 获取竞赛数据(无需认证) // 获取竞赛数据(无需认证)
async getCompetition(): Promise<CompetitionData> { async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`); const res = await fetch(`${API_BASE}/competition`)
if (!res.ok) throw new Error('获取竞赛数据失败'); if (!res.ok) throw new Error('获取竞赛数据失败')
return res.json(); 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`, { const res = await fetch(`${API_BASE}/user/signal-sources`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); })
if (!res.ok) throw new Error('获取用户信号源配置失败'); if (!res.ok) throw new Error('获取用户信号源配置失败')
return res.json(); 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`, { const res = await fetch(`${API_BASE}/user/signal-sources`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
@@ -310,7 +324,7 @@ export const api = {
coin_pool_url: coinPoolUrl, coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl, oi_top_url: oiTopUrl,
}), }),
}); })
if (!res.ok) throw new Error('保存用户信号源配置失败'); if (!res.ok) throw new Error('保存用户信号源配置失败')
}, },
}; }

View File

@@ -1,28 +1,26 @@
export interface SystemConfig { export interface SystemConfig {
admin_mode: boolean; admin_mode: boolean
beta_mode: boolean; beta_mode: boolean
} }
let configPromise: Promise<SystemConfig> | null = null; let configPromise: Promise<SystemConfig> | null = null
let cachedConfig: SystemConfig | null = null; let cachedConfig: SystemConfig | null = null
export function getSystemConfig(): Promise<SystemConfig> { export function getSystemConfig(): Promise<SystemConfig> {
if (cachedConfig) { if (cachedConfig) {
return Promise.resolve(cachedConfig); return Promise.resolve(cachedConfig)
} }
if (configPromise) { if (configPromise) {
return configPromise; return configPromise
} }
configPromise = fetch('/api/config') configPromise = fetch('/api/config')
.then((res) => res.json()) .then((res) => res.json())
.then((data: SystemConfig) => { .then((data: SystemConfig) => {
cachedConfig = data; cachedConfig = data
return data; return data
}) })
.finally(() => { .finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later // Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
}); })
return configPromise; return configPromise
} }

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from 'clsx'
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }

View File

@@ -6,5 +6,5 @@ import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
) )

View File

@@ -19,8 +19,8 @@ export function LandingPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { language, setLanguage } = useLanguage() const { language, setLanguage } = useLanguage()
const isLoggedIn = !!user const isLoggedIn = !!user
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn); console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn)
return ( return (
<> <>
<HeaderBar <HeaderBar
@@ -43,7 +43,7 @@ export function LandingPage() {
}} }}
/> />
<div <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={{ style={{
background: 'var(--brand-black)', background: 'var(--brand-black)',
color: 'var(--brand-light-gray)', color: 'var(--brand-light-gray)',
@@ -56,10 +56,10 @@ export function LandingPage() {
<CommunitySection /> <CommunitySection />
{/* CTA */} {/* CTA */}
<AnimatedSection backgroundColor='var(--panel-bg)'> <AnimatedSection backgroundColor="var(--panel-bg)">
<div className='max-w-4xl mx-auto text-center'> <div className="max-w-4xl mx-auto text-center">
<motion.h2 <motion.h2
className='text-5xl font-bold mb-6' className="text-5xl font-bold mb-6"
style={{ color: 'var(--brand-light-gray)' }} style={{ color: 'var(--brand-light-gray)' }}
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -68,7 +68,7 @@ export function LandingPage() {
{t('readyToDefine', language)} {t('readyToDefine', language)}
</motion.h2> </motion.h2>
<motion.p <motion.p
className='text-xl mb-12' className="text-xl mb-12"
style={{ color: 'var(--text-secondary)' }} style={{ color: 'var(--text-secondary)' }}
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -77,16 +77,10 @@ export function LandingPage() {
> >
{t('startWithCrypto', language)} {t('startWithCrypto', language)}
</motion.p> </motion.p>
<div className='flex flex-wrap justify-center gap-4'> <div className="flex flex-wrap justify-center gap-4">
<motion.button <motion.button
onClick={() => { onClick={() => setShowLoginModal(true)}
if (isLoggedIn) { className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
window.location.href = '/traders'
} else {
setShowLoginModal(true)
}
}}
className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg'
style={{ style={{
background: 'var(--brand-yellow)', background: 'var(--brand-yellow)',
color: 'var(--brand-black)', color: 'var(--brand-black)',
@@ -99,14 +93,14 @@ export function LandingPage() {
animate={{ x: [0, 5, 0] }} animate={{ x: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }} transition={{ duration: 1.5, repeat: Infinity }}
> >
<ArrowRight className='w-5 h-5' /> <ArrowRight className="w-5 h-5" />
</motion.div> </motion.div>
</motion.button> </motion.button>
<motion.a <motion.a
href='https://github.com/tinkle-community/nofx/tree/dev' href="https://github.com/tinkle-community/nofx/tree/dev"
target='_blank' target="_blank"
rel='noopener noreferrer' rel="noopener noreferrer"
className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
style={{ style={{
background: 'transparent', background: 'transparent',
color: 'var(--brand-light-gray)', color: 'var(--brand-light-gray)',

View File

@@ -1,204 +1,204 @@
export interface SystemStatus { export interface SystemStatus {
trader_id: string; trader_id: string
trader_name: string; trader_name: string
ai_model: string; ai_model: string
is_running: boolean; is_running: boolean
start_time: string; start_time: string
runtime_minutes: number; runtime_minutes: number
call_count: number; call_count: number
initial_balance: number; initial_balance: number
scan_interval: string; scan_interval: string
stop_until: string; stop_until: string
last_reset_time: string; last_reset_time: string
ai_provider: string; ai_provider: string
} }
export interface AccountInfo { export interface AccountInfo {
total_equity: number; total_equity: number
wallet_balance: number; wallet_balance: number
unrealized_profit: number; unrealized_profit: number
available_balance: number; available_balance: number
total_pnl: number; total_pnl: number
total_pnl_pct: number; total_pnl_pct: number
total_unrealized_pnl: number; total_unrealized_pnl: number
initial_balance: number; initial_balance: number
daily_pnl: number; daily_pnl: number
position_count: number; position_count: number
margin_used: number; margin_used: number
margin_used_pct: number; margin_used_pct: number
} }
export interface Position { export interface Position {
symbol: string; symbol: string
side: string; side: string
entry_price: number; entry_price: number
mark_price: number; mark_price: number
quantity: number; quantity: number
leverage: number; leverage: number
unrealized_pnl: number; unrealized_pnl: number
unrealized_pnl_pct: number; unrealized_pnl_pct: number
liquidation_price: number; liquidation_price: number
margin_used: number; margin_used: number
} }
export interface DecisionAction { export interface DecisionAction {
action: string; action: string
symbol: string; symbol: string
quantity: number; quantity: number
leverage: number; leverage: number
price: number; price: number
order_id: number; order_id: number
timestamp: string; timestamp: string
success: boolean; success: boolean
error?: string; error?: string
} }
export interface AccountSnapshot { export interface AccountSnapshot {
total_balance: number; total_balance: number
available_balance: number; available_balance: number
total_unrealized_profit: number; total_unrealized_profit: number
position_count: number; position_count: number
margin_used_pct: number; margin_used_pct: number
} }
export interface DecisionRecord { export interface DecisionRecord {
timestamp: string; timestamp: string
cycle_number: number; cycle_number: number
input_prompt: string; input_prompt: string
cot_trace: string; cot_trace: string
decision_json: string; decision_json: string
account_state: AccountSnapshot; account_state: AccountSnapshot
positions: any[]; positions: any[]
candidate_coins: string[]; candidate_coins: string[]
decisions: DecisionAction[]; decisions: DecisionAction[]
execution_log: string[]; execution_log: string[]
success: boolean; success: boolean
error_message?: string; error_message?: string
} }
export interface Statistics { export interface Statistics {
total_cycles: number; total_cycles: number
successful_cycles: number; successful_cycles: number
failed_cycles: number; failed_cycles: number
total_open_positions: number; total_open_positions: number
total_close_positions: number; total_close_positions: number
} }
// AI Trading相关类型 // AI Trading相关类型
export interface TraderInfo { export interface TraderInfo {
trader_id: string; trader_id: string
trader_name: string; trader_name: string
ai_model: string; ai_model: string
exchange_id?: string; exchange_id?: string
is_running?: boolean; is_running?: boolean
custom_prompt?: string; custom_prompt?: string
} }
export interface AIModel { export interface AIModel {
id: string; id: string
name: string; name: string
provider: string; provider: string
enabled: boolean; enabled: boolean
apiKey?: string; apiKey?: string
customApiUrl?: string; customApiUrl?: string
customModelName?: string; customModelName?: string
} }
export interface Exchange { export interface Exchange {
id: string; id: string
name: string; name: string
type: 'cex' | 'dex'; type: 'cex' | 'dex'
enabled: boolean; enabled: boolean
apiKey?: string; apiKey?: string
secretKey?: string; secretKey?: string
testnet?: boolean; testnet?: boolean
// Hyperliquid 特定字段 // Hyperliquid 特定字段
hyperliquidWalletAddr?: string; hyperliquidWalletAddr?: string
// Aster 特定字段 // Aster 特定字段
asterUser?: string; asterUser?: string
asterSigner?: string; asterSigner?: string
asterPrivateKey?: string; asterPrivateKey?: string
} }
export interface CreateTraderRequest { export interface CreateTraderRequest {
name: string; name: string
ai_model_id: string; ai_model_id: string
exchange_id: string; exchange_id: string
initial_balance: number; initial_balance: number
scan_interval_minutes?: number; scan_interval_minutes?: number
btc_eth_leverage?: number; btc_eth_leverage?: number
altcoin_leverage?: number; altcoin_leverage?: number
trading_symbols?: string; trading_symbols?: string
custom_prompt?: string; custom_prompt?: string
override_base_prompt?: boolean; override_base_prompt?: boolean
system_prompt_template?: string; system_prompt_template?: string
is_cross_margin?: boolean; is_cross_margin?: boolean
use_coin_pool?: boolean; use_coin_pool?: boolean
use_oi_top?: boolean; use_oi_top?: boolean
} }
export interface UpdateModelConfigRequest { export interface UpdateModelConfigRequest {
models: { models: {
[key: string]: { [key: string]: {
enabled: boolean; enabled: boolean
api_key: string; api_key: string
custom_api_url?: string; custom_api_url?: string
custom_model_name?: string; custom_model_name?: string
}; }
}; }
} }
export interface UpdateExchangeConfigRequest { export interface UpdateExchangeConfigRequest {
exchanges: { exchanges: {
[key: string]: { [key: string]: {
enabled: boolean; enabled: boolean
api_key: string; api_key: string
secret_key: string; secret_key: string
testnet?: boolean; testnet?: boolean
// Hyperliquid 特定字段 // Hyperliquid 特定字段
hyperliquid_wallet_addr?: string; hyperliquid_wallet_addr?: string
// Aster 特定字段 // Aster 特定字段
aster_user?: string; aster_user?: string
aster_signer?: string; aster_signer?: string
aster_private_key?: string; aster_private_key?: string
}; }
}; }
} }
// Competition related types // Competition related types
export interface CompetitionTraderData { export interface CompetitionTraderData {
trader_id: string; trader_id: string
trader_name: string; trader_name: string
ai_model: string; ai_model: string
exchange: string; exchange: string
total_equity: number; total_equity: number
total_pnl: number; total_pnl: number
total_pnl_pct: number; total_pnl_pct: number
position_count: number; position_count: number
margin_used_pct: number; margin_used_pct: number
is_running: boolean; is_running: boolean
} }
export interface CompetitionData { export interface CompetitionData {
traders: CompetitionTraderData[]; traders: CompetitionTraderData[]
count: number; count: number
} }
// Trader Configuration Data for View Modal // Trader Configuration Data for View Modal
export interface TraderConfigData { export interface TraderConfigData {
trader_id?: string; trader_id?: string
trader_name: string; trader_name: string
ai_model: string; ai_model: string
exchange_id: string; exchange_id: string
btc_eth_leverage: number; btc_eth_leverage: number
altcoin_leverage: number; altcoin_leverage: number
trading_symbols: string; trading_symbols: string
custom_prompt: string; custom_prompt: string
override_base_prompt: boolean; override_base_prompt: boolean
is_cross_margin: boolean; is_cross_margin: boolean
use_coin_pool: boolean; use_coin_pool: boolean
use_oi_top: boolean; use_oi_top: boolean
initial_balance: number; initial_balance: number
scan_interval_minutes: number; scan_interval_minutes: number
is_running: boolean; is_running: boolean
} }

View File

@@ -1,93 +1,93 @@
// 系统状态 // 系统状态
export interface SystemStatus { export interface SystemStatus {
is_running: boolean; is_running: boolean
start_time: string; start_time: string
runtime_minutes: number; runtime_minutes: number
call_count: number; call_count: number
initial_balance: number; initial_balance: number
scan_interval: string; scan_interval: string
stop_until: string; stop_until: string
last_reset_time: string; last_reset_time: string
ai_provider: string; ai_provider: string
} }
// 账户信息 // 账户信息
export interface AccountInfo { export interface AccountInfo {
total_equity: number; total_equity: number
available_balance: number; available_balance: number
total_pnl: number; total_pnl: number
total_pnl_pct: number; total_pnl_pct: number
total_unrealized_pnl: number; total_unrealized_pnl: number
margin_used: number; margin_used: number
margin_used_pct: number; margin_used_pct: number
position_count: number; position_count: number
initial_balance: number; initial_balance: number
daily_pnl: number; daily_pnl: number
} }
// 持仓信息 // 持仓信息
export interface Position { export interface Position {
symbol: string; symbol: string
side: string; side: string
entry_price: number; entry_price: number
mark_price: number; mark_price: number
quantity: number; quantity: number
leverage: number; leverage: number
unrealized_pnl: number; unrealized_pnl: number
unrealized_pnl_pct: number; unrealized_pnl_pct: number
liquidation_price: number; liquidation_price: number
margin_used: number; margin_used: number
} }
// 决策动作 // 决策动作
export interface DecisionAction { export interface DecisionAction {
action: string; action: string
symbol: string; symbol: string
quantity: number; quantity: number
leverage: number; leverage: number
price: number; price: number
order_id: number; order_id: number
timestamp: string; timestamp: string
success: boolean; success: boolean
error: string; error: string
} }
// 决策记录 // 决策记录
export interface DecisionRecord { export interface DecisionRecord {
timestamp: string; timestamp: string
cycle_number: number; cycle_number: number
input_prompt: string; input_prompt: string
cot_trace: string; cot_trace: string
decision_json: string; decision_json: string
account_state: { account_state: {
total_balance: number; total_balance: number
available_balance: number; available_balance: number
total_unrealized_profit: number; total_unrealized_profit: number
position_count: number; position_count: number
margin_used_pct: number; margin_used_pct: number
}; }
positions: Array<{ positions: Array<{
symbol: string; symbol: string
side: string; side: string
position_amt: number; position_amt: number
entry_price: number; entry_price: number
mark_price: number; mark_price: number
unrealized_profit: number; unrealized_profit: number
leverage: number; leverage: number
liquidation_price: number; liquidation_price: number
}>; }>
candidate_coins: string[]; candidate_coins: string[]
decisions: DecisionAction[]; decisions: DecisionAction[]
execution_log: string[]; execution_log: string[]
success: boolean; success: boolean
error_message: string; error_message: string
} }
// 统计信息 // 统计信息
export interface Statistics { export interface Statistics {
total_cycles: number; total_cycles: number
successful_cycles: number; successful_cycles: number
failed_cycles: number; failed_cycles: number
total_open_positions: number; total_open_positions: number
total_close_positions: number; total_close_positions: number
} }

View File

@@ -12,7 +12,7 @@ export const TRADER_COLORS = [
'#a78bfa', // violet-400 '#a78bfa', // violet-400
'#4ade80', // green-400 '#4ade80', // green-400
'#fb7185', // rose-400 '#fb7185', // rose-400
]; ]
/** /**
* 根据trader的索引位置获取颜色 * 根据trader的索引位置获取颜色
@@ -24,8 +24,8 @@ export function getTraderColor(
traders: Array<{ trader_id: string }>, traders: Array<{ trader_id: string }>,
traderId: string traderId: string
): string { ): string {
const traderIndex = traders.findIndex((t) => t.trader_id === traderId); const traderIndex = traders.findIndex((t) => t.trader_id === traderId)
if (traderIndex === -1) return TRADER_COLORS[0]; // 默认返回第一个颜色 if (traderIndex === -1) return TRADER_COLORS[0] // 默认返回第一个颜色
// 如果超出颜色池大小,循环使用 // 如果超出颜色池大小,循环使用
return TRADER_COLORS[traderIndex % TRADER_COLORS.length]; return TRADER_COLORS[traderIndex % TRADER_COLORS.length]
} }