Files
nofx/web/src/App.tsx
tinkle-community 94ef009bb5 refactor: remove all Debate Arena feature code
Remove the entire AI Debate Arena module (~5,300 lines) to simplify
the codebase. This removes the multi-AI debate trading decision system
including backend engine, API handlers, database store, frontend page,
navigation, translations, and documentation references.
2026-03-11 17:32:41 +08:00

675 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import useSWR from 'swr'
import { api } from './lib/api'
import { TraderDashboardPage } from './pages/TraderDashboardPage'
import { AITradersPage } from './components/AITradersPage'
import { LoginPage } from './components/LoginPage'
import { SetupPage } from './components/SetupPage'
import { SettingsPage } from './pages/SettingsPage'
import { ResetPasswordPage } from './components/ResetPasswordPage'
import { CompetitionPage } from './components/CompetitionPage'
import { LandingPage } from './pages/LandingPage'
import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import { StrategyMarketPage } from './pages/StrategyMarketPage'
import { DataPage } from './pages/DataPage'
import { LoginRequiredOverlay } from './components/LoginRequiredOverlay'
import HeaderBar from './components/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/ConfirmDialog'
import { t } from './i18n/translations'
import { useSystemConfig } from './hooks/useSystemConfig'
import { OFFICIAL_LINKS } from './constants/branding'
import { BacktestPage } from './components/BacktestPage'
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
Exchange,
} from './types'
type Page =
| 'competition'
| 'traders'
| 'trader'
| 'backtest'
| 'strategy'
| 'strategy-market'
| 'data'
| 'faq'
| 'login'
| 'register'
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const [route, setRoute] = useState(window.location.pathname)
// Debug log
useEffect(() => {
console.log('[App] Mounted. Route:', window.location.pathname);
}, []);
// 从URL路径读取初始页面状态支持刷新保持页面
const getInitialPage = (): Page => {
const path = window.location.pathname
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/backtest' || hash === 'backtest') return 'backtest'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
if (path === '/data' || hash === 'data') return 'data'
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
return 'trader'
return 'competition' // 默认为竞赛页面
}
// Login required overlay state
const [loginOverlayOpen, setLoginOverlayOpen] = useState(false)
const [loginOverlayFeature, setLoginOverlayFeature] = useState('')
const handleLoginRequired = (featureName: string) => {
setLoginOverlayFeature(featureName)
setLoginOverlayOpen(true)
}
// Unified page navigation handler
const navigateToPage = (page: Page) => {
const pathMap: Record<Page, string> = {
'competition': '/competition',
'strategy-market': '/strategy-market',
'data': '/data',
'traders': '/traders',
'trader': '/dashboard',
'backtest': '/backtest',
'strategy': '/strategy',
'faq': '/faq',
'login': '/login',
'register': '/register',
}
const path = pathMap[page]
if (path) {
window.history.pushState({}, '', path)
setRoute(path)
setCurrentPage(page)
}
}
const [currentPage, setCurrentPage] = useState<Page>(getInitialPage())
// 从 URL 参数读取初始 trader 标识(格式: name-id前4位
const [selectedTraderSlug, setSelectedTraderSlug] = useState<string | undefined>(() => {
const params = new URLSearchParams(window.location.search)
return params.get('trader') || undefined
})
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>()
// 生成 trader URL slugname + ID 前 4 位)
const getTraderSlug = (trader: TraderInfo) => {
const idPrefix = trader.trader_id.slice(0, 4)
return `${trader.trader_name}-${idPrefix}`
}
// 从 slug 解析并匹配 trader
const findTraderBySlug = (slug: string, traderList: TraderInfo[]) => {
// slug 格式: name-xxxx (xxxx 是 ID 前 4 位)
const lastDashIndex = slug.lastIndexOf('-')
if (lastDashIndex === -1) {
// 没有 dash直接按 name 匹配
return traderList.find(t => t.trader_name === slug)
}
const name = slug.slice(0, lastDashIndex)
const idPrefix = slug.slice(lastDashIndex + 1)
return traderList.find(t =>
t.trader_name === name && t.trader_id.startsWith(idPrefix)
)
}
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
// 监听URL变化同步页面状态
useEffect(() => {
const handleRouteChange = () => {
const path = window.location.pathname
const hash = window.location.hash.slice(1)
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/backtest' || hash === 'backtest') {
setCurrentPage('backtest')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
} else if (path === '/strategy-market' || hash === 'strategy-market') {
setCurrentPage('strategy-market')
} else if (path === '/data' || hash === 'data') {
setCurrentPage('data')
} else if (
path === '/dashboard' ||
hash === 'trader' ||
hash === 'details'
) {
setCurrentPage('trader')
// 如果 URL 中有 trader 参数slug 格式),更新选中的 trader
if (traderParam) {
setSelectedTraderSlug(traderParam)
}
} else if (
path === '/competition' ||
hash === 'competition' ||
hash === ''
) {
setCurrentPage('competition')
}
setRoute(path)
}
window.addEventListener('hashchange', handleRouteChange)
window.addEventListener('popstate', handleRouteChange)
return () => {
window.removeEventListener('hashchange', handleRouteChange)
window.removeEventListener('popstate', handleRouteChange)
}
}, [])
// 切换页面时更新URL hash (当前通过按钮直接调用setCurrentPage这个函数暂时保留用于未来扩展)
// const navigateToPage = (page: Page) => {
// setCurrentPage(page);
// window.location.hash = page === 'competition' ? '' : 'trader';
// };
// 获取trader列表仅在用户登录时
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{
refreshInterval: 10000,
shouldRetryOnError: false, // 避免在后端未运行时无限重试
}
)
// 获取exchanges列表用于显示交易所名称
const { data: exchanges } = useSWR<Exchange[]>(
user && token ? 'exchanges' : null,
api.getExchangeConfigs,
{
refreshInterval: 60000, // 1分钟刷新一次
shouldRetryOnError: false,
}
)
// 当获取到traders后根据 URL 中的 trader slug 设置选中的 trader或默认选中第一个
useEffect(() => {
if (traders && traders.length > 0 && !selectedTraderId) {
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
if (trader) {
setSelectedTraderId(trader.trader_id)
} else {
// 如果找不到,选中第一个
setSelectedTraderId(traders[0].trader_id)
}
} else {
setSelectedTraderId(traders[0].trader_id)
}
}
}, [traders, selectedTraderId, selectedTraderSlug])
// 如果在trader页面获取该trader的数据
const { data: status } = useSWR<SystemStatus>(
currentPage === 'trader' && selectedTraderId
? `status-${selectedTraderId}`
: null,
() => api.getStatus(selectedTraderId),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
}
)
const { data: account } = useSWR<AccountInfo>(
currentPage === 'trader' && selectedTraderId
? `account-${selectedTraderId}`
: null,
() => api.getAccount(selectedTraderId),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
}
)
const { data: positions } = useSWR<Position[]>(
currentPage === 'trader' && selectedTraderId
? `positions-${selectedTraderId}`
: null,
() => api.getPositions(selectedTraderId),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
}
)
const { data: decisions } = useSWR<DecisionRecord[]>(
currentPage === 'trader' && selectedTraderId
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
: null,
() => api.getLatestDecisions(selectedTraderId, decisionsLimit),
{
refreshInterval: 30000, // 30秒刷新决策更新频率较低
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
const { data: stats } = useSWR<Statistics>(
currentPage === 'trader' && selectedTraderId
? `statistics-${selectedTraderId}`
: null,
() => api.getStatistics(selectedTraderId),
{
refreshInterval: 30000, // 30秒刷新统计数据更新频率较低
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
useEffect(() => {
if (account) {
const now = new Date().toLocaleTimeString()
setLastUpdate(now)
}
}, [account])
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
// Handle routing
useEffect(() => {
const handlePopState = () => {
setRoute(window.location.pathname)
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
// Set current page based on route for consistent navigation state
useEffect(() => {
if (route === '/competition') {
setCurrentPage('competition')
} else if (route === '/traders') {
setCurrentPage('traders')
} else if (route === '/dashboard') {
setCurrentPage('trader')
}
}, [route])
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: '#0B0E11' }}
>
<div className="text-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 mx-auto mb-4 animate-pulse"
/>
<p style={{ color: '#EAECEF' }}>{t('loading', language)}</p>
</div>
</div>
)
}
// First-time setup: redirect to /setup if system not initialized
if (systemConfig && !systemConfig.initialized && !user) {
return <SetupPage />
}
// Handle specific routes regardless of authentication
if (route === '/login') {
return <LoginPage />
}
if (route === '/setup') {
// If already initialized, redirect to login
if (systemConfig?.initialized) {
window.location.href = '/login'
return null
}
return <SetupPage />
}
if (route === '/faq') {
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="faq"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<FAQPage />
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
if (route === '/reset-password') {
return <ResetPasswordPage />
}
if (route === '/settings') {
if (!user || !token) {
window.location.href = '/login'
return null
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
<HeaderBar
isLoggedIn={!!user}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<SettingsPage />
</div>
)
}
// Data page - publicly accessible with embedded dashboard
if (route === '/data') {
const dataPageNavigate = (page: Page) => {
const pathMap: Record<string, string> = {
'data': '/data',
'competition': '/competition',
'strategy-market': '/strategy-market',
'traders': '/traders',
'trader': '/dashboard',
'backtest': '/backtest',
'strategy': '/strategy',
'faq': '/faq',
}
const path = pathMap[page]
if (path) {
window.location.href = path
}
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage="data"
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={dataPageNavigate}
/>
<main className="pt-16">
<DataPage />
</main>
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
// Show landing page for root route
if (route === '/' || route === '') {
return <LandingPage />
}
// Redirect unauthenticated users to landing page
if (!user || !token) {
return <LandingPage />
}
return (
<div
className="min-h-screen"
style={{ background: '#0B0E11', color: '#EAECEF' }}
>
<HeaderBar
isLoggedIn={!!user}
currentPage={currentPage}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
{/* Main Content with Page Transitions */}
<main className="min-h-screen pt-16">
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'data' ? (
<DataPage />
) : currentPage === 'strategy-market' ? (
<StrategyMarketPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
window.history.pushState({}, '', '/dashboard')
setRoute('/dashboard')
setCurrentPage('trader')
}}
/>
) : currentPage === 'backtest' ? (
<BacktestPage />
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : (
<TraderDashboardPage
selectedTrader={selectedTrader}
status={status}
account={account}
positions={positions}
decisions={decisions}
decisionsLimit={decisionsLimit}
onDecisionsLimitChange={setDecisionsLimit}
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
tradersError={tradersError}
selectedTraderId={selectedTraderId}
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
// 更新 URL 参数(使用 slug: name-id前4位
const trader = traders?.find(t => t.trader_id === traderId)
if (trader) {
const url = new URL(window.location.href)
url.searchParams.set('trader', getTraderSlug(trader))
window.history.replaceState({}, '', url.toString())
}
}}
onNavigateToTraders={() => {
window.history.pushState({}, '', '/traders')
setRoute('/traders')
setCurrentPage('traders')
}}
exchanges={exchanges}
/>
)}
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
<div
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
style={{ color: '#5E6673' }}
>
<p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
{/* GitHub */}
<a
href={OFFICIAL_LINKS.github}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#F0B90B'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</a>
{/* Twitter/X */}
<a
href={OFFICIAL_LINKS.twitter}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#1DA1F2'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Twitter
</a>
{/* Telegram */}
<a
href={OFFICIAL_LINKS.telegram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#848E9C',
border: '1px solid #2B3139',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2B3139'
e.currentTarget.style.color = '#EAECEF'
e.currentTarget.style.borderColor = '#0088cc'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1E2329'
e.currentTarget.style.color = '#848E9C'
e.currentTarget.style.borderColor = '#2B3139'
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
Telegram
</a>
</div>
</div>
</footer>
{/* Login Required Overlay */}
<LoginRequiredOverlay
isOpen={loginOverlayOpen}
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
</div>
)
}
// Wrap App with providers
export default function AppWithProviders() {
return (
<LanguageProvider>
<AuthProvider>
<ConfirmDialogProvider>
<App />
</ConfirmDialogProvider>
</AuthProvider>
</LanguageProvider>
)
}