mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-03 11:00:58 +08:00
Refactor(UI) : Refactor Frontend: Unified Toasts with Sonner, Introduced Layout System, and Integrated React Router (#872)
This commit is contained in:
863
web/package-lock.json
generated
863
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -23,7 +24,9 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-password-checklist": "^1.8.1",
|
"react-password-checklist": "^1.8.1",
|
||||||
|
"react-router-dom": "^7.9.5",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
|
|||||||
1284
web/src/App.tsx
1284
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
|
import { stripLeadingIcons } from '../lib/text'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
@@ -78,7 +79,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
|||||||
className="rounded p-6"
|
className="rounded p-6"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||||
>
|
>
|
||||||
<div style={{ color: '#F6465D' }}>{t('loadingError', language)}</div>
|
<div style={{ color: '#F6465D' }}>
|
||||||
|
{stripLeadingIcons(t('loadingError', language))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -695,7 +698,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
|||||||
style={{ color: '#E0E7FF' }}
|
style={{ color: '#E0E7FF' }}
|
||||||
>
|
>
|
||||||
<BarChart3 className="w-5 h-5" />{' '}
|
<BarChart3 className="w-5 h-5" />{' '}
|
||||||
{t('symbolPerformance', language)}
|
{stripLeadingIcons(t('symbolPerformance', language))}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1084,7 +1087,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
|||||||
className="font-bold mb-3 text-base"
|
className="font-bold mb-3 text-base"
|
||||||
style={{ color: '#FCD34D' }}
|
style={{ color: '#FCD34D' }}
|
||||||
>
|
>
|
||||||
{t('howAILearns', language)}
|
{stripLeadingIcons(t('howAILearns', language))}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type {
|
import type {
|
||||||
@@ -29,7 +30,10 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Radio,
|
Radio,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { confirmToast } from '../lib/notify'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
// 获取友好的AI模型名称
|
// 获取友好的AI模型名称
|
||||||
function getModelDisplayName(modelId: string): string {
|
function getModelDisplayName(modelId: string): string {
|
||||||
@@ -58,6 +62,7 @@ interface AITradersPageProps {
|
|||||||
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [showModelModal, setShowModelModal] = useState(false)
|
const [showModelModal, setShowModelModal] = useState(false)
|
||||||
@@ -220,21 +225,25 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
||||||
|
|
||||||
if (!model?.enabled) {
|
if (!model?.enabled) {
|
||||||
alert(t('modelNotConfigured', language))
|
toast.error(t('modelNotConfigured', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exchange?.enabled) {
|
if (!exchange?.enabled) {
|
||||||
alert(t('exchangeNotConfigured', language))
|
toast.error(t('exchangeNotConfigured', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.createTrader(data)
|
await toast.promise(api.createTrader(data), {
|
||||||
|
loading: '正在创建…',
|
||||||
|
success: '创建成功',
|
||||||
|
error: '创建失败',
|
||||||
|
})
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
mutateTraders()
|
mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create trader:', error)
|
console.error('Failed to create trader:', error)
|
||||||
alert(t('createTraderFailed', language))
|
toast.error(t('createTraderFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +254,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch trader config:', error)
|
console.error('Failed to fetch trader config:', error)
|
||||||
alert(t('getTraderConfigFailed', language))
|
toast.error(t('getTraderConfigFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +266,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id)
|
const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id)
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
alert(t('modelConfigNotExist', language))
|
toast.error(t('modelConfigNotExist', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exchange) {
|
if (!exchange) {
|
||||||
alert(t('exchangeConfigNotExist', language))
|
toast.error(t('exchangeConfigNotExist', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,39 +291,58 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
use_oi_top: data.use_oi_top,
|
use_oi_top: data.use_oi_top,
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateTrader(editingTrader.trader_id, request)
|
await toast.promise(api.updateTrader(editingTrader.trader_id, request), {
|
||||||
|
loading: '正在保存…',
|
||||||
|
success: '保存成功',
|
||||||
|
error: '保存失败',
|
||||||
|
})
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
setEditingTrader(null)
|
setEditingTrader(null)
|
||||||
mutateTraders()
|
mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update trader:', error)
|
console.error('Failed to update trader:', error)
|
||||||
alert(t('updateTraderFailed', language))
|
toast.error(t('updateTraderFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteTrader = async (traderId: string) => {
|
const handleDeleteTrader = async (traderId: string) => {
|
||||||
if (!confirm(t('confirmDeleteTrader', language))) return
|
{
|
||||||
|
const ok = await confirmToast(t('confirmDeleteTrader', language))
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteTrader(traderId)
|
await toast.promise(api.deleteTrader(traderId), {
|
||||||
|
loading: '正在删除…',
|
||||||
|
success: '删除成功',
|
||||||
|
error: '删除失败',
|
||||||
|
})
|
||||||
mutateTraders()
|
mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete trader:', error)
|
console.error('Failed to delete trader:', error)
|
||||||
alert(t('deleteTraderFailed', language))
|
toast.error(t('deleteTraderFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleTrader = async (traderId: string, running: boolean) => {
|
const handleToggleTrader = async (traderId: string, running: boolean) => {
|
||||||
try {
|
try {
|
||||||
if (running) {
|
if (running) {
|
||||||
await api.stopTrader(traderId)
|
await toast.promise(api.stopTrader(traderId), {
|
||||||
|
loading: '正在停止…',
|
||||||
|
success: '已停止',
|
||||||
|
error: '停止失败',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await api.startTrader(traderId)
|
await toast.promise(api.startTrader(traderId), {
|
||||||
|
loading: '正在启动…',
|
||||||
|
success: '已启动',
|
||||||
|
error: '启动失败',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
mutateTraders()
|
mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle trader:', error)
|
console.error('Failed to toggle trader:', error)
|
||||||
alert(t('operationFailed', language))
|
toast.error(t('operationFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,19 +381,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
if (config.checkInUse(config.id)) {
|
if (config.checkInUse(config.id)) {
|
||||||
const usingTraders = config.getUsingTraders(config.id)
|
const usingTraders = config.getUsingTraders(config.id)
|
||||||
const traderNames = usingTraders.map((t) => t.trader_name).join(', ')
|
const traderNames = usingTraders.map((t) => t.trader_name).join(', ')
|
||||||
alert(
|
toast.error(
|
||||||
t(config.cannotDeleteKey, language) +
|
`${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}`
|
||||||
'\n\n' +
|
|
||||||
t('tradersUsing', language) +
|
|
||||||
': ' +
|
|
||||||
traderNames +
|
|
||||||
'\n\n' +
|
|
||||||
t('pleaseDeleteTradersFirst', language)
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(t(config.confirmDeleteKey, language))) return
|
{
|
||||||
|
const ok = await confirmToast(t(config.confirmDeleteKey, language))
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedItems =
|
const updatedItems =
|
||||||
@@ -374,7 +399,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
) || []
|
) || []
|
||||||
|
|
||||||
const request = config.buildRequest(updatedItems)
|
const request = config.buildRequest(updatedItems)
|
||||||
await config.updateApi(request)
|
await toast.promise(config.updateApi(request), {
|
||||||
|
loading: '正在更新配置…',
|
||||||
|
success: '配置已更新',
|
||||||
|
error: '更新配置失败',
|
||||||
|
})
|
||||||
|
|
||||||
// 重新获取用户配置以确保数据同步
|
// 重新获取用户配置以确保数据同步
|
||||||
const refreshedItems = await config.refreshApi()
|
const refreshedItems = await config.refreshApi()
|
||||||
@@ -383,7 +412,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
config.closeModal()
|
config.closeModal()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete ${config.type} config:`, error)
|
console.error(`Failed to delete ${config.type} config:`, error)
|
||||||
alert(t(config.errorKey, language))
|
toast.error(t(config.errorKey, language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +474,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const modelToUpdate =
|
const modelToUpdate =
|
||||||
existingModel || supportedModels?.find((m) => m.id === modelId)
|
existingModel || supportedModels?.find((m) => m.id === modelId)
|
||||||
if (!modelToUpdate) {
|
if (!modelToUpdate) {
|
||||||
alert(t('modelNotExist', language))
|
toast.error(t('modelNotExist', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +518,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateModelConfigs(request)
|
await toast.promise(api.updateModelConfigs(request), {
|
||||||
|
loading: '正在更新模型配置…',
|
||||||
|
success: '模型配置已更新',
|
||||||
|
error: '更新模型配置失败',
|
||||||
|
})
|
||||||
|
|
||||||
// 重新获取用户配置以确保数据同步
|
// 重新获取用户配置以确保数据同步
|
||||||
const refreshedModels = await api.getModelConfigs()
|
const refreshedModels = await api.getModelConfigs()
|
||||||
@@ -499,7 +532,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setEditingModel(null)
|
setEditingModel(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save model config:', error)
|
console.error('Failed to save model config:', error)
|
||||||
alert(t('saveConfigFailed', language))
|
toast.error(t('saveConfigFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +602,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
(e) => e.id === exchangeId
|
(e) => e.id === exchangeId
|
||||||
)
|
)
|
||||||
if (!exchangeToUpdate) {
|
if (!exchangeToUpdate) {
|
||||||
alert(t('exchangeNotExist', language))
|
toast.error(t('exchangeNotExist', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +662,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateExchangeConfigsEncrypted(request)
|
await toast.promise(api.updateExchangeConfigsEncrypted(request), {
|
||||||
|
loading: '正在更新交易所配置…',
|
||||||
|
success: '交易所配置已更新',
|
||||||
|
error: '更新交易所配置失败',
|
||||||
|
})
|
||||||
|
|
||||||
// 重新获取用户配置以确保数据同步
|
// 重新获取用户配置以确保数据同步
|
||||||
const refreshedExchanges = await api.getExchangeConfigs()
|
const refreshedExchanges = await api.getExchangeConfigs()
|
||||||
@@ -639,7 +676,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setEditingExchange(null)
|
setEditingExchange(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save exchange config:', error)
|
console.error('Failed to save exchange config:', error)
|
||||||
alert(t('saveConfigFailed', language))
|
toast.error(t('saveConfigFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,12 +695,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
oiTopUrl: string
|
oiTopUrl: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await api.saveUserSignalSource(coinPoolUrl, oiTopUrl)
|
await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), {
|
||||||
|
loading: '正在保存…',
|
||||||
|
success: '保存成功',
|
||||||
|
error: '保存失败',
|
||||||
|
})
|
||||||
setUserSignalSource({ coinPoolUrl, oiTopUrl })
|
setUserSignalSource({ coinPoolUrl, oiTopUrl })
|
||||||
setShowSignalSourceModal(false)
|
setShowSignalSourceModal(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save signal source:', error)
|
console.error('Failed to save signal source:', error)
|
||||||
alert(t('saveSignalSourceFailed', language))
|
toast.error(t('saveSignalSourceFailed', language))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,9 +1066,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
|
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
{/* <div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||||
{t('status', language)}
|
{t('status', language)}
|
||||||
</div>
|
</div> */}
|
||||||
<div
|
<div
|
||||||
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${
|
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${
|
||||||
trader.is_running
|
trader.is_running
|
||||||
@@ -1052,10 +1093,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions: 禁止换行,超出横向滚动 */}
|
||||||
<div className="flex gap-1.5 md:gap-2 flex-wrap md:flex-nowrap">
|
<div className="flex gap-1.5 md:gap-2 flex-nowrap overflow-x-auto items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => onTraderSelect?.(trader.trader_id)}
|
onClick={() => {
|
||||||
|
if (onTraderSelect) {
|
||||||
|
onTraderSelect(trader.trader_id)
|
||||||
|
} else {
|
||||||
|
navigate(`/dashboard?trader=${trader.trader_id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"
|
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(99, 102, 241, 0.1)',
|
background: 'rgba(99, 102, 241, 0.1)',
|
||||||
@@ -1069,7 +1116,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleEditTrader(trader.trader_id)}
|
onClick={() => handleEditTrader(trader.trader_id)}
|
||||||
disabled={trader.is_running}
|
disabled={trader.is_running}
|
||||||
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap flex items-center gap-1"
|
||||||
style={{
|
style={{
|
||||||
background: trader.is_running
|
background: trader.is_running
|
||||||
? 'rgba(132, 142, 156, 0.1)'
|
? 'rgba(132, 142, 156, 0.1)'
|
||||||
@@ -1077,7 +1124,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
color: trader.is_running ? '#848E9C' : '#FFC107',
|
color: trader.is_running ? '#848E9C' : '#FFC107',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✏️ {t('edit', language)}
|
<Pencil className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
{t('edit', language)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1788,6 +1836,7 @@ function ExchangeConfigModal({
|
|||||||
await navigator.clipboard.writeText(ip)
|
await navigator.clipboard.writeText(ip)
|
||||||
setCopiedIP(true)
|
setCopiedIP(true)
|
||||||
setTimeout(() => setCopiedIP(false), 2000)
|
setTimeout(() => setCopiedIP(false), 2000)
|
||||||
|
toast.success(t('ipCopied', language))
|
||||||
} else {
|
} else {
|
||||||
// 降级方案: 使用传统的 execCommand 方法
|
// 降级方案: 使用传统的 execCommand 方法
|
||||||
const textArea = document.createElement('textarea')
|
const textArea = document.createElement('textarea')
|
||||||
@@ -1804,6 +1853,7 @@ function ExchangeConfigModal({
|
|||||||
if (successful) {
|
if (successful) {
|
||||||
setCopiedIP(true)
|
setCopiedIP(true)
|
||||||
setTimeout(() => setCopiedIP(false), 2000)
|
setTimeout(() => setCopiedIP(false), 2000)
|
||||||
|
toast.success(t('ipCopied', language))
|
||||||
} else {
|
} else {
|
||||||
throw new Error('复制命令执行失败')
|
throw new Error('复制命令执行失败')
|
||||||
}
|
}
|
||||||
@@ -1814,7 +1864,7 @@ function ExchangeConfigModal({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('复制失败:', err)
|
console.error('复制失败:', err)
|
||||||
// 显示错误提示
|
// 显示错误提示
|
||||||
alert(t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址`)
|
toast.error(t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
web/src/components/ConfirmDialog.tsx
Normal file
123
web/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from './ui/alert-dialog'
|
||||||
|
import { setGlobalConfirm } from '../lib/notify'
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
okText?: string
|
||||||
|
cancelText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmDialogContextType {
|
||||||
|
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialogContext = createContext<
|
||||||
|
ConfirmDialogContextType | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
export function useConfirmDialog() {
|
||||||
|
const context = useContext(ConfirmDialogContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useConfirmDialog must be used within ConfirmDialogProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
isOpen: boolean
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
okText: string
|
||||||
|
cancelText: string
|
||||||
|
resolve?: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialogProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<ConfirmState>({
|
||||||
|
isOpen: false,
|
||||||
|
message: '',
|
||||||
|
okText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
okText: options.okText || '确认',
|
||||||
|
cancelText: options.cancelText || '取消',
|
||||||
|
resolve,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 注册全局 confirm 函数
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalConfirm(confirm)
|
||||||
|
}, [confirm])
|
||||||
|
|
||||||
|
const handleClose = useCallback((result: boolean) => {
|
||||||
|
setState((prev) => {
|
||||||
|
prev.resolve?.(result)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
isOpen: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialogContext.Provider value={{ confirm }}>
|
||||||
|
{children}
|
||||||
|
<AlertDialog
|
||||||
|
open={state.isOpen}
|
||||||
|
onOpenChange={(open) => !open && handleClose(false)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<div className="flex flex-col gap-5 text-center">
|
||||||
|
{state.title && (
|
||||||
|
<AlertDialogTitle className="text-xl">
|
||||||
|
{state.title}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
)}
|
||||||
|
<AlertDialogDescription className="text-[var(--text-primary)] text-base font-medium">
|
||||||
|
{state.message}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => handleClose(false)}>
|
||||||
|
{state.cancelText}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleClose(true)}>
|
||||||
|
{state.okText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</ConfirmDialogContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
web/src/components/Container.tsx
Normal file
40
web/src/components/Container.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ReactNode, CSSProperties } from 'react'
|
||||||
|
|
||||||
|
interface ContainerProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
as?: 'div' | 'main' | 'header' | 'section'
|
||||||
|
style?: CSSProperties
|
||||||
|
/** 是否充满宽度(取消 max-width) */
|
||||||
|
fluid?: boolean
|
||||||
|
/** 是否取消水平内边距 */
|
||||||
|
noPadding?: boolean
|
||||||
|
/** 自定义最大宽度类(默认 max-w-[1920px]) */
|
||||||
|
maxWidthClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的容器组件,确保所有页面元素使用一致的最大宽度和内边距
|
||||||
|
* - max-width: 1920px
|
||||||
|
* - padding: 24px (mobile) -> 32px (tablet) -> 48px (desktop)
|
||||||
|
*/
|
||||||
|
export function Container({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
as: Component = 'div',
|
||||||
|
style,
|
||||||
|
fluid = false,
|
||||||
|
noPadding = false,
|
||||||
|
maxWidthClass = 'max-w-[1920px]',
|
||||||
|
}: ContainerProps) {
|
||||||
|
const maxWidth = fluid ? 'w-full' : maxWidthClass
|
||||||
|
const padding = noPadding ? 'px-0' : 'px-6 sm:px-8 lg:px-12'
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={`${maxWidth} mx-auto ${padding} ${className}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
web/src/components/DevToastController.tsx
Normal file
116
web/src/components/DevToastController.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
|
|
||||||
|
const toastOptions = [
|
||||||
|
'message',
|
||||||
|
'success',
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
'custom',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type ToastType = (typeof toastOptions)[number]
|
||||||
|
|
||||||
|
const customRenderer = () => (
|
||||||
|
<div className="dev-custom-toast">
|
||||||
|
<p className="dev-custom-title">Sonner 自定义通知</p>
|
||||||
|
<p className="dev-custom-body">
|
||||||
|
这是一个通过 `notify.custom` 渲染的测试 Toast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export function DevToastController() {
|
||||||
|
const [type, setType] = useState<ToastType>('success')
|
||||||
|
const [message, setMessage] = useState('来自 Dev 控制器的测试通知')
|
||||||
|
const [duration, setDuration] = useState(2200)
|
||||||
|
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerToast = async () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'message':
|
||||||
|
notify.message(message, { duration })
|
||||||
|
break
|
||||||
|
case 'success':
|
||||||
|
notify.success(message, { duration })
|
||||||
|
break
|
||||||
|
case 'info':
|
||||||
|
notify.info(message, { duration })
|
||||||
|
break
|
||||||
|
case 'warning':
|
||||||
|
notify.warning(message, { duration })
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
notify.error(message, { duration })
|
||||||
|
break
|
||||||
|
case 'custom':
|
||||||
|
notify.custom(() => customRenderer(), { duration })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerConfirm = async () => {
|
||||||
|
const confirmed = await confirmToast(message, {
|
||||||
|
okText: '继续',
|
||||||
|
cancelText: '取消',
|
||||||
|
})
|
||||||
|
if (confirmed) {
|
||||||
|
notify.success('确认按钮已点击', { duration: 2000 })
|
||||||
|
} else {
|
||||||
|
notify.message('已取消确认逻辑', { duration: 2000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dev-toast-controller">
|
||||||
|
<div className="dev-toast-controller__header">
|
||||||
|
<span>Dev Sonner 控制器</span>
|
||||||
|
<small>仅在 dev 模式可见</small>
|
||||||
|
</div>
|
||||||
|
<div className="dev-toast-controller__content">
|
||||||
|
<label className="dev-toast-controller__label">
|
||||||
|
类型
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => setType(event.target.value as ToastType)}
|
||||||
|
>
|
||||||
|
{toastOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="dev-toast-controller__label">
|
||||||
|
文案
|
||||||
|
<input
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
placeholder="输入通知/确认文案"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="dev-toast-controller__label">
|
||||||
|
持续(ms)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={600}
|
||||||
|
value={duration}
|
||||||
|
onChange={(event) => setDuration(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="dev-toast-controller__actions">
|
||||||
|
<button onClick={triggerToast}>触发通知</button>
|
||||||
|
<button onClick={triggerConfirm}>触发确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DevToastController
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
|
import { Container } from './Container'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
simple?: boolean // For login/register pages
|
simple?: boolean // For login/register pages
|
||||||
@@ -10,7 +11,7 @@ export function Header({ simple = false }: HeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
||||||
<div className="max-w-[1920px] mx-auto px-6 py-4">
|
<Container className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left - Logo and Title */}
|
{/* Left - Logo and Title */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -58,7 +59,7 @@ export function Header({ simple = false }: HeaderProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
921
web/src/components/HeaderBar.tsx
Normal file
921
web/src/components/HeaderBar.tsx
Normal file
@@ -0,0 +1,921 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||||
|
import { t, type Language } from '../i18n/translations'
|
||||||
|
import { Container } from './Container'
|
||||||
|
|
||||||
|
interface HeaderBarProps {
|
||||||
|
onLoginClick?: () => void
|
||||||
|
isLoggedIn?: boolean
|
||||||
|
isHomePage?: boolean
|
||||||
|
currentPage?: string
|
||||||
|
language?: Language
|
||||||
|
onLanguageChange?: (lang: Language) => void
|
||||||
|
user?: { email: string } | null
|
||||||
|
onLogout?: () => void
|
||||||
|
onPageChange?: (page: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderBar({
|
||||||
|
isLoggedIn = false,
|
||||||
|
isHomePage = false,
|
||||||
|
currentPage,
|
||||||
|
language = 'zh' as Language,
|
||||||
|
onLanguageChange,
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
onPageChange,
|
||||||
|
}: HeaderBarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||||
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setLanguageDropdownOpen(false)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
userDropdownRef.current &&
|
||||||
|
!userDropdownRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setUserDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||||
|
<Container className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||||
|
<span
|
||||||
|
className="text-xl font-bold"
|
||||||
|
style={{ color: 'var(--brand-yellow)' }}
|
||||||
|
>
|
||||||
|
NOFX
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-sm hidden sm:block"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Agentic Trading OS
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Menu */}
|
||||||
|
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||||
|
{/* Left Side - Navigation Tabs */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
// Main app navigation when logged in
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/competition')
|
||||||
|
}}
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'competition'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'competition') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'competition') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'competition' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('realtimeNav', language)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/traders')
|
||||||
|
}}
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'traders'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'traders') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'traders') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'traders' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('configNav', language)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}}
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'trader'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'trader') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'trader') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'trader' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('dashboardNav', language)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onPageChange) {
|
||||||
|
onPageChange('faq')
|
||||||
|
} else {
|
||||||
|
navigate('/faq')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'faq'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'faq') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'faq') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'faq' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('faqNav', language)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Landing page navigation when not logged in
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="/competition"
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'competition'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'competition') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'competition') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'competition' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('realtimeNav', language)}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/faq"
|
||||||
|
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'faq'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentPage !== 'faq') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentPage !== 'faq') {
|
||||||
|
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'faq' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('faqNav', language)}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Original Navigation Items and Login */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* Only show original navigation items on home page */}
|
||||||
|
{isHomePage &&
|
||||||
|
[
|
||||||
|
{ key: 'features', label: t('features', language) },
|
||||||
|
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||||
|
{ key: 'GitHub', label: 'GitHub' },
|
||||||
|
{ key: 'community', label: t('community', language) },
|
||||||
|
].map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.key}
|
||||||
|
href={
|
||||||
|
item.key === 'GitHub'
|
||||||
|
? 'https://github.com/tinkle-community/nofx'
|
||||||
|
: item.key === 'community'
|
||||||
|
? 'https://t.me/nofx_dev_community'
|
||||||
|
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||||
|
}
|
||||||
|
target={
|
||||||
|
item.key === 'GitHub' || item.key === 'community'
|
||||||
|
? '_blank'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rel={
|
||||||
|
item.key === 'GitHub' || item.key === 'community'
|
||||||
|
? 'noopener noreferrer'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="text-sm transition-colors relative group"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
|
||||||
|
style={{ background: 'var(--brand-yellow)' }}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* User Info and Actions */}
|
||||||
|
{isLoggedIn && user ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* User Info with Dropdown */}
|
||||||
|
<div className="relative" ref={userDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background =
|
||||||
|
'rgba(255, 255, 255, 0.05)')
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = 'var(--panel-bg)')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className="w-4 h-4"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{userDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-dark-gray)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 border-b"
|
||||||
|
style={{ borderColor: 'var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('loggedInAs', language)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLogout()
|
||||||
|
setUserDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('exitLogin', language)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||||
|
currentPage !== 'login' &&
|
||||||
|
currentPage !== 'register' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('signIn', language)}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('signUp', language)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Language Toggle - Always at the rightmost */}
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background =
|
||||||
|
'rgba(255, 255, 255, 0.05)')
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = 'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-lg">
|
||||||
|
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{languageDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-dark-gray)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLanguageChange?.('zh')
|
||||||
|
setLanguageDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||||
|
language === 'zh' ? '' : 'hover:opacity-80'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
background:
|
||||||
|
language === 'zh'
|
||||||
|
? 'rgba(240, 185, 11, 0.1)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-base">🇨🇳</span>
|
||||||
|
<span className="text-sm">中文</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLanguageChange?.('en')
|
||||||
|
setLanguageDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||||
|
language === 'en' ? '' : 'hover:opacity-80'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
background:
|
||||||
|
language === 'en'
|
||||||
|
? 'rgba(240, 185, 11, 0.1)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-base">🇺🇸</span>
|
||||||
|
<span className="text-sm">English</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="md:hidden"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={
|
||||||
|
mobileMenuOpen
|
||||||
|
? { height: 'auto', opacity: 1 }
|
||||||
|
: { height: 0, opacity: 0 }
|
||||||
|
}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="md:hidden overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-dark-gray)',
|
||||||
|
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-4 space-y-3">
|
||||||
|
{/* New Navigation Tabs */}
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
console.log(
|
||||||
|
'移动端 实时 button clicked, onPageChange:',
|
||||||
|
onPageChange
|
||||||
|
)
|
||||||
|
onPageChange?.('competition')
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'competition'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'competition' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('realtimeNav', language)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="/competition"
|
||||||
|
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'competition'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'competition' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('realtimeNav', language)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{/* Only show 配置 and 看板 when logged in */}
|
||||||
|
{isLoggedIn && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onPageChange) {
|
||||||
|
onPageChange('traders')
|
||||||
|
} else {
|
||||||
|
navigate('/traders')
|
||||||
|
}
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'traders'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'traders' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('configNav', language)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onPageChange) {
|
||||||
|
onPageChange('trader')
|
||||||
|
} else {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'trader'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'trader' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('dashboardNav', language)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onPageChange) {
|
||||||
|
onPageChange('faq')
|
||||||
|
} else {
|
||||||
|
navigate('/faq')
|
||||||
|
}
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
currentPage === 'faq'
|
||||||
|
? 'var(--brand-yellow)'
|
||||||
|
: 'var(--brand-light-gray)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background for selected state */}
|
||||||
|
{currentPage === 'faq' && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.15)',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t('faqNav', language)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Original Navigation Items - Only on home page */}
|
||||||
|
{isHomePage &&
|
||||||
|
[
|
||||||
|
{ key: 'features', label: t('features', language) },
|
||||||
|
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||||
|
{ key: 'GitHub', label: 'GitHub' },
|
||||||
|
{ key: 'community', label: t('community', language) },
|
||||||
|
].map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.key}
|
||||||
|
href={
|
||||||
|
item.key === 'GitHub'
|
||||||
|
? 'https://github.com/tinkle-community/nofx'
|
||||||
|
: item.key === 'community'
|
||||||
|
? 'https://t.me/nofx_dev_community'
|
||||||
|
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||||
|
}
|
||||||
|
target={
|
||||||
|
item.key === 'GitHub' || item.key === 'community'
|
||||||
|
? '_blank'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rel={
|
||||||
|
item.key === 'GitHub' || item.key === 'community'
|
||||||
|
? 'noopener noreferrer'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="block text-sm py-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Language Toggle */}
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('language', language)}:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLanguageChange?.('zh')
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||||
|
language === 'zh'
|
||||||
|
? 'bg-yellow-500 text-black'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">🇨🇳</span>
|
||||||
|
<span className="text-sm">中文</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLanguageChange?.('en')
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||||
|
language === 'en'
|
||||||
|
? 'bg-yellow-500 text-black'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">🇺🇸</span>
|
||||||
|
<span className="text-sm">English</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info and logout for mobile when logged in */}
|
||||||
|
{isLoggedIn && user && (
|
||||||
|
<div
|
||||||
|
className="mt-4 pt-4"
|
||||||
|
style={{ borderTop: '1px solid var(--panel-border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
||||||
|
style={{ background: 'var(--panel-bg)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.email[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('loggedInAs', language)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLogout()
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('exitLogin', language)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
||||||
|
{!isLoggedIn &&
|
||||||
|
currentPage !== 'login' &&
|
||||||
|
currentPage !== 'register' && (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
||||||
|
style={{
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
border: '1px solid var(--brand-light-gray)',
|
||||||
|
}}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{t('signIn', language)}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{t('signUp', language)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
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 { Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { login, loginAdmin, verifyOTP } = useAuth()
|
const { login, loginAdmin, verifyOTP } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
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('')
|
||||||
@@ -26,7 +28,9 @@ export function LoginPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await loginAdmin(adminPassword)
|
const result = await loginAdmin(adminPassword)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.message || t('loginFailed', language))
|
const msg = result.message || t('loginFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -44,7 +48,9 @@ export function LoginPage() {
|
|||||||
setStep('otp')
|
setStep('otp')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || t('loginFailed', language))
|
const msg = result.message || t('loginFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -58,7 +64,9 @@ export function LoginPage() {
|
|||||||
const result = await verifyOTP(userID, otpCode)
|
const result = await verifyOTP(userID, otpCode)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.message || t('verificationFailed', language))
|
const msg = result.message || t('verificationFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
// 成功的话AuthContext会自动处理登录状态
|
// 成功的话AuthContext会自动处理登录状态
|
||||||
|
|
||||||
@@ -66,286 +74,259 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
<div
|
||||||
<HeaderBar
|
className="flex items-center justify-center py-12"
|
||||||
onLoginClick={() => {}}
|
style={{ minHeight: 'calc(100vh - 64px)' }}
|
||||||
isLoggedIn={false}
|
>
|
||||||
isHomePage={false}
|
<div className="w-full max-w-md">
|
||||||
currentPage="login"
|
{/* Logo */}
|
||||||
language={language}
|
<div className="text-center mb-8">
|
||||||
onLanguageChange={() => {}}
|
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||||
onPageChange={(page) => {
|
<img
|
||||||
console.log('LoginPage onPageChange called with:', page)
|
src="/icons/nofx.svg"
|
||||||
if (page === 'competition') {
|
alt="NoFx Logo"
|
||||||
window.location.href = '/competition'
|
className="w-16 h-16 object-contain"
|
||||||
}
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center pt-20"
|
|
||||||
style={{ minHeight: 'calc(100vh - 80px)' }}
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src="/icons/nofx.svg"
|
|
||||||
alt="NoFx Logo"
|
|
||||||
className="w-16 h-16 object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
className="text-2xl font-bold"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
登录 NOFX
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="text-sm mt-2"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1
|
||||||
{/* Login Form */}
|
className="text-2xl font-bold"
|
||||||
<div
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
className="rounded-lg p-6"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{adminMode ? (
|
登录 NOFX
|
||||||
<form onSubmit={handleAdminLogin} className="space-y-4">
|
</h1>
|
||||||
<div>
|
<p
|
||||||
<label
|
className="text-sm mt-2"
|
||||||
className="block text-sm font-semibold mb-2"
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
>
|
||||||
>
|
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
|
||||||
管理员密码
|
</p>
|
||||||
</label>
|
</div>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={adminPassword}
|
|
||||||
onChange={(e) => setAdminPassword(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="请输入管理员密码"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{/* Login Form */}
|
||||||
<div
|
<div
|
||||||
className="text-sm px-3 py-2 rounded"
|
className="rounded-lg p-6"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--binance-red-bg)',
|
background: 'var(--panel-bg)',
|
||||||
color: 'var(--binance-red)',
|
border: '1px solid var(--panel-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{adminMode ? (
|
||||||
</div>
|
<form onSubmit={handleAdminLogin} className="space-y-4">
|
||||||
)}
|
<div>
|
||||||
|
<label
|
||||||
<button
|
className="block text-sm font-semibold mb-2"
|
||||||
type="submit"
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
disabled={loading}
|
>
|
||||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
管理员密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--brand-yellow)',
|
background: 'var(--brand-black)',
|
||||||
color: 'var(--brand-black)',
|
border: '1px solid var(--panel-border)',
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
}}
|
||||||
|
placeholder="请输入管理员密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm px-3 py-2 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? t('loading', language) : '登录'}
|
{error}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
)}
|
||||||
) : step === 'login' ? (
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<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)',
|
||||||
{t('email', language)}
|
color: 'var(--brand-black)',
|
||||||
</label>
|
}}
|
||||||
|
>
|
||||||
|
{loading ? t('loading', language) : '登录'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : step === 'login' ? (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('email', language)}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
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>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={email}
|
value={password}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t('emailPlaceholder', language)}
|
className="pr-10"
|
||||||
|
placeholder={t('passwordPlaceholder', language)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('password', language)}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="pr-10"
|
|
||||||
placeholder={t('passwordPlaceholder', language)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-right mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
window.history.pushState({}, '', '/reset-password')
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
||||||
}}
|
|
||||||
className="text-xs hover:underline"
|
|
||||||
style={{ color: '#F0B90B' }}
|
|
||||||
>
|
|
||||||
{t('forgotPassword', language)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="text-sm px-3 py-2 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--binance-red-bg)',
|
|
||||||
color: 'var(--binance-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? t('loading', language)
|
|
||||||
: t('loginButton', language)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<div className="text-4xl mb-2">📱</div>
|
|
||||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
|
||||||
{t('scanQRCodeInstructions', language)}
|
|
||||||
<br />
|
|
||||||
{t('enterOTPCode', language)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('otpCode', language)}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={otpCode}
|
|
||||||
onChange={(e) =>
|
|
||||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-black)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
}}
|
|
||||||
placeholder={t('otpPlaceholder', language)}
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="text-sm px-3 py-2 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--binance-red-bg)',
|
|
||||||
color: 'var(--binance-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep('login')}
|
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
style={{
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
background: 'var(--panel-bg-hover)',
|
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||||
color: 'var(--text-secondary)',
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('back', language)}
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="text-right mt-2">
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => navigate('/reset-password')}
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
>
|
||||||
|
{t('forgotPassword', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Register Link */}
|
{error && (
|
||||||
{!adminMode && (
|
<div
|
||||||
<div className="text-center mt-6">
|
className="text-sm px-3 py-2 rounded"
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
style={{
|
||||||
还没有账户?{' '}
|
background: 'var(--binance-red-bg)',
|
||||||
<button
|
color: 'var(--binance-red)',
|
||||||
onClick={() => {
|
|
||||||
window.history.pushState({}, '', '/register')
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
||||||
}}
|
}}
|
||||||
className="font-semibold hover:underline transition-colors"
|
|
||||||
style={{ color: 'var(--brand-yellow)' }}
|
|
||||||
>
|
>
|
||||||
立即注册
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? t('loading', language) : t('loginButton', language)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-4xl mb-2">📱</div>
|
||||||
|
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{t('scanQRCodeInstructions', language)}
|
||||||
|
<br />
|
||||||
|
{t('enterOTPCode', language)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('otpCode', language)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-black)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
}}
|
||||||
|
placeholder={t('otpPlaceholder', language)}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm px-3 py-2 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
</p>
|
<button
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* Register Link */}
|
||||||
|
{!adminMode && (
|
||||||
|
<div className="text-center mt-6">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
还没有账户?{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
className="font-semibold hover:underline transition-colors"
|
||||||
|
style={{ color: 'var(--brand-yellow)' }}
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
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 { toast } from 'sonner'
|
||||||
|
import { copyWithToast } from '../lib/clipboard'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
import PasswordChecklist from 'react-password-checklist'
|
import PasswordChecklist from 'react-password-checklist'
|
||||||
@@ -11,6 +13,7 @@ import PasswordChecklist from 'react-password-checklist'
|
|||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { register, completeRegistration } = useAuth()
|
const { register, completeRegistration } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
|
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
|
||||||
'register'
|
'register'
|
||||||
)
|
)
|
||||||
@@ -66,7 +69,9 @@ export function RegisterPage() {
|
|||||||
setQrCodeURL(result.qrCodeURL || '')
|
setQrCodeURL(result.qrCodeURL || '')
|
||||||
setStep('setup-otp')
|
setStep('setup-otp')
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || t('registrationFailed', language))
|
const msg = result.message || t('registrationFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -84,7 +89,9 @@ export function RegisterPage() {
|
|||||||
const result = await completeRegistration(userID, otpCode)
|
const result = await completeRegistration(userID, otpCode)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.message || t('registrationFailed', language))
|
const msg = result.message || t('registrationFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
// 成功的话AuthContext会自动处理登录状态
|
// 成功的话AuthContext会自动处理登录状态
|
||||||
|
|
||||||
@@ -92,141 +99,197 @@ export function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
copyWithToast(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
<div
|
||||||
<HeaderBar
|
className="flex items-center justify-center py-12"
|
||||||
isLoggedIn={false}
|
style={{ minHeight: 'calc(100vh - 64px)' }}
|
||||||
isHomePage={false}
|
>
|
||||||
currentPage="register"
|
<div className="w-full max-w-md">
|
||||||
language={language}
|
{/* Logo */}
|
||||||
onLanguageChange={() => {}}
|
<div className="text-center mb-8">
|
||||||
onPageChange={(page) => {
|
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||||
console.log('RegisterPage onPageChange called with:', page)
|
<img
|
||||||
if (page === 'competition') {
|
src="/icons/nofx.svg"
|
||||||
window.location.href = '/competition'
|
alt="NoFx Logo"
|
||||||
}
|
className="w-16 h-16 object-contain"
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center pt-20"
|
|
||||||
style={{ minHeight: 'calc(100vh - 80px)' }}
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
|
||||||
<img
|
|
||||||
src="/icons/nofx.svg"
|
|
||||||
alt="NoFx Logo"
|
|
||||||
className="w-16 h-16 object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<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
|
<div
|
||||||
className="rounded-lg p-6"
|
className="rounded-lg p-6"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--panel-bg)',
|
background: 'var(--panel-bg)',
|
||||||
border: '1px solid var(--panel-border)',
|
border: '1px solid var(--panel-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{step === 'register' && (
|
{step === 'register' && (
|
||||||
<form onSubmit={handleRegister} className="space-y-4">
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-semibold mb-2"
|
className="block text-sm font-semibold mb-2"
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
>
|
>
|
||||||
{t('email', language)}
|
{t('email', language)}
|
||||||
</label>
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
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>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={email}
|
value={password}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t('emailPlaceholder', language)}
|
className="pr-10"
|
||||||
|
placeholder={t('passwordPlaceholder', language)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
<div>
|
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||||
<label
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
className="block text-sm font-semibold mb-2"
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
{t('password', language)}
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
</label>
|
</button>
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="pr-10"
|
|
||||||
placeholder={t('passwordPlaceholder', language)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-semibold mb-2"
|
className="block text-sm font-semibold mb-2"
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('confirmPassword', language)}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="pr-10"
|
||||||
|
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={showConfirmPassword ? '隐藏密码' : '显示密码'}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setShowConfirmPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
{t('confirmPassword', language)}
|
{showConfirmPassword ? (
|
||||||
</label>
|
<EyeOff size={18} />
|
||||||
<div className="relative">
|
) : (
|
||||||
<Input
|
<Eye size={18} />
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
)}
|
||||||
value={confirmPassword}
|
</button>
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
className="pr-10"
|
|
||||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={showConfirmPassword ? '隐藏密码' : '显示密码'}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setShowConfirmPassword((v) => !v)}
|
|
||||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff size={18} />
|
|
||||||
) : (
|
|
||||||
<Eye size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 密码规则清单(通过才允许提交) */}
|
{/* 密码规则清单(通过才允许提交) */}
|
||||||
|
<div
|
||||||
|
className="mt-1 text-xs"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="mt-1 text-xs"
|
className="mb-1"
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('passwordRequirements', language)}
|
||||||
|
</div>
|
||||||
|
<PasswordChecklist
|
||||||
|
rules={[
|
||||||
|
'minLength',
|
||||||
|
'capital',
|
||||||
|
'lowercase',
|
||||||
|
'number',
|
||||||
|
'specialChar',
|
||||||
|
'match',
|
||||||
|
]}
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
valueAgain={confirmPassword}
|
||||||
|
messages={{
|
||||||
|
minLength: t('passwordRuleMinLength', language),
|
||||||
|
capital: t('passwordRuleUppercase', language),
|
||||||
|
lowercase: t('passwordRuleLowercase', language),
|
||||||
|
number: t('passwordRuleNumber', language),
|
||||||
|
specialChar: t('passwordRuleSpecial', language),
|
||||||
|
match: t('passwordRuleMatch', language),
|
||||||
|
}}
|
||||||
|
className="space-y-1"
|
||||||
|
onChange={(isValid) => setPasswordValid(isValid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{betaMode && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-semibold mb-2"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
内测码 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={betaCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBetaCode(
|
||||||
|
e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded font-mono"
|
||||||
|
style={{
|
||||||
|
background: '#0B0E11',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
placeholder="请输入6位内测码"
|
||||||
|
maxLength={6}
|
||||||
|
required={betaMode}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||||
|
内测码由6位字母数字组成,区分大小写
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm px-3 py-2 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mb-1"
|
className="mb-1"
|
||||||
@@ -259,297 +322,245 @@ export function RegisterPage() {
|
|||||||
onChange={(isValid) => setPasswordValid(isValid)}
|
onChange={(isValid) => setPasswordValid(isValid)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{betaMode && (
|
<button
|
||||||
<div>
|
type="submit"
|
||||||
<label
|
disabled={
|
||||||
className="block text-sm font-semibold mb-2"
|
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||||
style={{ color: '#EAECEF' }}
|
}
|
||||||
>
|
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||||
内测码 *
|
style={{
|
||||||
</label>
|
background: 'var(--brand-yellow)',
|
||||||
<input
|
color: 'var(--brand-black)',
|
||||||
type="text"
|
}}
|
||||||
value={betaCode}
|
>
|
||||||
onChange={(e) =>
|
{loading
|
||||||
setBetaCode(
|
? t('loading', language)
|
||||||
e.target.value
|
: t('registerButton', language)}
|
||||||
.replace(/[^a-z0-9]/gi, '')
|
</button>
|
||||||
.toLowerCase()
|
</form>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{step === 'setup-otp' && (
|
||||||
<div
|
<div className="space-y-4">
|
||||||
className="text-sm px-3 py-2 rounded"
|
<div className="text-center">
|
||||||
style={{
|
<div className="text-4xl mb-2">📱</div>
|
||||||
background: 'var(--binance-red-bg)',
|
<h3
|
||||||
color: 'var(--binance-red)',
|
className="text-lg font-semibold mb-2"
|
||||||
}}
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{t('setupTwoFactor', language)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{t('setupTwoFactorDesc', language)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="p-3 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-black)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
>
|
>
|
||||||
{error}
|
{t('authStep1Title', language)}
|
||||||
</div>
|
</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">
|
||||||
|
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||||
|
{t('otpSecret', language)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
className="flex-1 px-2 py-1 text-xs rounded font-mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg-hover)',
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{otpSecret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(otpSecret)}
|
||||||
|
className="px-2 py-1 text-xs rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-yellow)',
|
||||||
|
color: 'var(--brand-black)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('copy', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="p-3 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-black)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('authStep3Title', language)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('authStep3Desc', language)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSetupComplete}
|
||||||
|
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||||
|
style={{ background: '#F0B90B', color: '#000' }}
|
||||||
|
>
|
||||||
|
{t('setupCompleteContinue', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'verify-otp' && (
|
||||||
|
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-4xl mb-2">🔐</div>
|
||||||
|
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{t('enterOTPCode', language)}
|
||||||
|
<br />
|
||||||
|
{t('completeRegistrationSubtitle', language)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-semibold mb-2"
|
||||||
|
style={{ color: 'var(--brand-light-gray)' }}
|
||||||
|
>
|
||||||
|
{t('otpCode', language)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-black)',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
color: 'var(--brand-light-gray)',
|
||||||
|
}}
|
||||||
|
placeholder={t('otpPlaceholder', language)}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm px-3 py-2 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'var(--binance-red-bg)',
|
||||||
|
color: 'var(--binance-red)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('setup-otp')}
|
||||||
|
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
background: 'var(--panel-bg-hover)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('back', language)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={loading || otpCode.length !== 6}
|
||||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
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' }}
|
||||||
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
|
{loading
|
||||||
? t('loading', language)
|
? t('loading', language)
|
||||||
: t('registerButton', language)}
|
: t('completeRegistration', language)}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'setup-otp' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-4xl mb-2">📱</div>
|
|
||||||
<h3
|
|
||||||
className="text-lg font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('setupTwoFactor', language)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
|
||||||
{t('setupTwoFactorDesc', language)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div
|
|
||||||
className="p-3 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-black)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-sm font-semibold mb-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('authStep1Title', language)}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{t('authStep1Desc', language)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="p-3 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-black)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-sm font-semibold mb-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('authStep2Title', language)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
|
||||||
{t('authStep2Desc', language)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{qrCodeURL && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p
|
|
||||||
className="text-xs mb-2"
|
|
||||||
style={{ color: '#848E9C' }}
|
|
||||||
>
|
|
||||||
{t('qrCodeHint', language)}
|
|
||||||
</p>
|
|
||||||
<div className="bg-white p-2 rounded text-center">
|
|
||||||
<img
|
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
|
||||||
alt="QR Code"
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
|
||||||
{t('otpSecret', language)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code
|
|
||||||
className="flex-1 px-2 py-1 text-xs rounded font-mono"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg-hover)',
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{otpSecret}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(otpSecret)}
|
|
||||||
className="px-2 py-1 text-xs rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('copy', language)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</form>
|
||||||
|
|
||||||
{step === 'verify-otp' && (
|
|
||||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<div className="text-4xl mb-2">🔐</div>
|
|
||||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
|
||||||
{t('enterOTPCode', language)}
|
|
||||||
<br />
|
|
||||||
{t('completeRegistrationSubtitle', language)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('otpCode', language)}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={otpCode}
|
|
||||||
onChange={(e) =>
|
|
||||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-black)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
}}
|
|
||||||
placeholder={t('otpPlaceholder', language)}
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="text-sm px-3 py-2 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'var(--binance-red-bg)',
|
|
||||||
color: 'var(--binance-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStep('setup-otp')}
|
|
||||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg-hover)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('back', language)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || otpCode.length !== 6}
|
|
||||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
|
||||||
style={{ background: '#F0B90B', color: '#000' }}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? t('loading', language)
|
|
||||||
: t('completeRegistration', language)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login Link */}
|
|
||||||
{step === 'register' && (
|
|
||||||
<div className="text-center mt-6">
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
已有账户?{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
window.history.pushState({}, '', '/login')
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
||||||
}}
|
|
||||||
className="font-semibold hover:underline transition-colors"
|
|
||||||
style={{ color: 'var(--brand-yellow)' }}
|
|
||||||
>
|
|
||||||
立即登录
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Login Link */}
|
||||||
|
{step === 'register' && (
|
||||||
|
<div className="text-center mt-6">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
已有账户?{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
className="font-semibold hover:underline transition-colors"
|
||||||
|
style={{ color: 'var(--brand-yellow)' }}
|
||||||
|
>
|
||||||
|
立即登录
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Header } from './Header'
|
|||||||
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||||
import PasswordChecklist from 'react-password-checklist'
|
import PasswordChecklist from 'react-password-checklist'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function ResetPasswordPage() {
|
export function ResetPasswordPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -38,13 +39,16 @@ export function ResetPasswordPage() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
||||||
// 3秒后跳转到登录页面
|
// 3秒后跳转到登录页面
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.history.pushState({}, '', '/login')
|
window.history.pushState({}, '', '/login')
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || t('resetPasswordFailed', language))
|
const msg = result.message || t('resetPasswordFailed', language)
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
||||||
|
|
||||||
// 提取下划线后面的名称部分
|
// 提取下划线后面的名称部分
|
||||||
function getShortName(fullName: string): string {
|
function getShortName(fullName: string): string {
|
||||||
@@ -217,12 +219,11 @@ export function TraderConfigModal({
|
|||||||
const currentBalance = data.total_equity || data.balance || 0
|
const currentBalance = data.total_equity || data.balance || 0
|
||||||
|
|
||||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||||
|
toast.success('已获取当前余额')
|
||||||
// 显示成功提示
|
|
||||||
console.log('已获取当前余额:', currentBalance)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取余额失败:', error)
|
console.error('获取余额失败:', error)
|
||||||
setBalanceFetchError('获取余额失败,请检查网络连接')
|
setBalanceFetchError('获取余额失败,请检查网络连接')
|
||||||
|
toast.error('获取余额失败,请检查网络连接')
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingBalance(false)
|
setIsFetchingBalance(false)
|
||||||
}
|
}
|
||||||
@@ -249,7 +250,11 @@ export function TraderConfigModal({
|
|||||||
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 toast.promise(onSave(saveData), {
|
||||||
|
loading: '正在保存…',
|
||||||
|
success: '保存成功',
|
||||||
|
error: '保存失败',
|
||||||
|
})
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存失败:', error)
|
console.error('保存失败:', error)
|
||||||
@@ -268,8 +273,12 @@ export function TraderConfigModal({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky top-0 z-10 rounded-t-xl">
|
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky top-0 z-10 rounded-t-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center text-black">
|
||||||
<span className="text-lg">{isEditMode ? '✏️' : '➕'}</span>
|
{isEditMode ? (
|
||||||
|
<Pencil className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-[#EAECEF]">
|
<h2 className="text-xl font-bold text-[#EAECEF]">
|
||||||
@@ -284,7 +293,7 @@ export function TraderConfigModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
|
className="w-8 h-8 rounded-lg text-[#848E9C] hover:text-[#EAECEF] hover:bg-[#2B3139] transition-colors flex items-center justify-center"
|
||||||
>
|
>
|
||||||
✕
|
<IconX className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import type { TraderConfigData } from '../types'
|
import type { TraderConfigData } from '../types'
|
||||||
|
|
||||||
// 提取下划线后面的名称部分
|
// 提取下划线后面的名称部分
|
||||||
@@ -27,8 +28,10 @@ export function TraderConfigViewModal({
|
|||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopiedField(fieldName)
|
setCopiedField(fieldName)
|
||||||
setTimeout(() => setCopiedField(null), 2000)
|
setTimeout(() => setCopiedField(null), 2000)
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy:', error)
|
console.error('Failed to copy:', error)
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const DEFAULT_LENGTH = 64
|
const DEFAULT_LENGTH = 64
|
||||||
|
|
||||||
@@ -99,12 +100,14 @@ export function TwoStageKeyModal({
|
|||||||
...obfuscationLog,
|
...obfuscationLog,
|
||||||
`Stage 1: ${new Date().toISOString()} - Auto copied obfuscation`,
|
`Stage 1: ${new Date().toISOString()} - Auto copied obfuscation`,
|
||||||
])
|
])
|
||||||
|
toast.success('已复制混淆字符串到剪贴板')
|
||||||
} catch {
|
} catch {
|
||||||
setClipboardStatus('failed')
|
setClipboardStatus('failed')
|
||||||
setObfuscationLog([
|
setObfuscationLog([
|
||||||
...obfuscationLog,
|
...obfuscationLog,
|
||||||
`Stage 1: ${new Date().toISOString()} - Auto copy failed, manual required`,
|
`Stage 1: ${new Date().toISOString()} - Auto copy failed, manual required`,
|
||||||
])
|
])
|
||||||
|
toast.error('复制失败,请手动复制混淆字符串')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setClipboardStatus('failed')
|
setClipboardStatus('failed')
|
||||||
@@ -112,6 +115,7 @@ export function TwoStageKeyModal({
|
|||||||
...obfuscationLog,
|
...obfuscationLog,
|
||||||
`Stage 1: ${new Date().toISOString()} - Clipboard API not available`,
|
`Stage 1: ${new Date().toISOString()} - Clipboard API not available`,
|
||||||
])
|
])
|
||||||
|
toast('当前浏览器不支持自动复制,请手动复制')
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { HelpCircle } from 'lucide-react'
|
import { HelpCircle } from 'lucide-react'
|
||||||
|
import { Container } from '../Container'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
import { FAQSearchBar } from './FAQSearchBar'
|
import { FAQSearchBar } from './FAQSearchBar'
|
||||||
import { FAQSidebar } from './FAQSidebar'
|
import { FAQSidebar } from './FAQSidebar'
|
||||||
@@ -57,7 +58,7 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pt-24">
|
<Container className="py-6 pt-24">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
<div className="flex items-center justify-center gap-3 mb-4">
|
||||||
@@ -176,6 +177,6 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,932 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
|
||||||
import { t, type Language } from '../../i18n/translations'
|
|
||||||
|
|
||||||
interface HeaderBarProps {
|
|
||||||
onLoginClick?: () => void
|
|
||||||
isLoggedIn?: boolean
|
|
||||||
isHomePage?: boolean
|
|
||||||
currentPage?: string
|
|
||||||
language?: Language
|
|
||||||
onLanguageChange?: (lang: Language) => void
|
|
||||||
user?: { email: string } | null
|
|
||||||
onLogout?: () => void
|
|
||||||
onPageChange?: (page: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HeaderBar({
|
|
||||||
isLoggedIn = false,
|
|
||||||
isHomePage = false,
|
|
||||||
currentPage,
|
|
||||||
language = 'zh' as Language,
|
|
||||||
onLanguageChange,
|
|
||||||
user,
|
|
||||||
onLogout,
|
|
||||||
onPageChange,
|
|
||||||
}: HeaderBarProps) {
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
||||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
userDropdownRef.current &&
|
|
||||||
!userDropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setUserDropdownOpen(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex items-center justify-between h-16">
|
|
||||||
{/* Logo */}
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
|
||||||
>
|
|
||||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
|
||||||
<span
|
|
||||||
className="text-xl font-bold"
|
|
||||||
style={{ color: 'var(--brand-yellow)' }}
|
|
||||||
>
|
|
||||||
NOFX
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-sm hidden sm:block"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Agentic Trading OS
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
|
||||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
|
||||||
{/* Left Side - Navigation Tabs */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{isLoggedIn ? (
|
|
||||||
// Main app navigation when logged in
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'实时 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('competition')
|
|
||||||
}}
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'competition'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'competition') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'competition') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'competition' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('realtimeNav', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'配置 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('traders')
|
|
||||||
}}
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'traders'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'traders') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'traders') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'traders' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('configNav', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'看板 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('trader')
|
|
||||||
}}
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'trader'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'trader') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'trader') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'trader' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('dashboardNav', language)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'FAQ button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('faq')
|
|
||||||
}}
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'faq'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'faq') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'faq') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'faq' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('faqNav', language)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Landing page navigation when not logged in
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href="/competition"
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'competition'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'competition') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'competition') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'competition' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('realtimeNav', language)}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/faq"
|
|
||||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'faq'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentPage !== 'faq') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentPage !== 'faq') {
|
|
||||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'faq' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('faqNav', language)}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Original Navigation Items and Login */}
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{/* Only show original navigation items on home page */}
|
|
||||||
{isHomePage &&
|
|
||||||
[
|
|
||||||
{ key: 'features', label: t('features', language) },
|
|
||||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
|
||||||
{ key: 'GitHub', label: 'GitHub' },
|
|
||||||
{ key: 'community', label: t('community', language) },
|
|
||||||
].map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.key}
|
|
||||||
href={
|
|
||||||
item.key === 'GitHub'
|
|
||||||
? 'https://github.com/tinkle-community/nofx'
|
|
||||||
: item.key === 'community'
|
|
||||||
? 'https://t.me/nofx_dev_community'
|
|
||||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
|
||||||
}
|
|
||||||
target={
|
|
||||||
item.key === 'GitHub' || item.key === 'community'
|
|
||||||
? '_blank'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rel={
|
|
||||||
item.key === 'GitHub' || item.key === 'community'
|
|
||||||
? 'noopener noreferrer'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="text-sm transition-colors relative group"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
<span
|
|
||||||
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
|
|
||||||
style={{ background: 'var(--brand-yellow)' }}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* User Info and Actions */}
|
|
||||||
{isLoggedIn && user ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* User Info with Dropdown */}
|
|
||||||
<div className="relative" ref={userDropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.background =
|
|
||||||
'rgba(255, 255, 255, 0.05)')
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.background = 'var(--panel-bg)')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="text-sm"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
className="w-4 h-4"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{userDropdownOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-dark-gray)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 border-b"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{t('loggedInAs', language)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-sm font-medium"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onLogout && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLogout()
|
|
||||||
setUserDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
|
||||||
style={{
|
|
||||||
background: 'var(--binance-red-bg)',
|
|
||||||
color: 'var(--binance-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('exitLogin', language)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
|
||||||
currentPage !== 'login' &&
|
|
||||||
currentPage !== 'register' && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('signIn', language)}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/register"
|
|
||||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('signUp', language)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Language Toggle - Always at the rightmost */}
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.background =
|
|
||||||
'rgba(255, 255, 255, 0.05)')
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.background = 'transparent')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="text-lg">
|
|
||||||
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{languageDropdownOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-dark-gray)',
|
|
||||||
border: '1px solid var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('zh')
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
|
||||||
language === 'zh' ? '' : 'hover:opacity-80'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
background:
|
|
||||||
language === 'zh'
|
|
||||||
? 'rgba(240, 185, 11, 0.1)'
|
|
||||||
: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-base">🇨🇳</span>
|
|
||||||
<span className="text-sm">中文</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('en')
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
|
||||||
language === 'en' ? '' : 'hover:opacity-80'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
background:
|
|
||||||
language === 'en'
|
|
||||||
? 'rgba(240, 185, 11, 0.1)'
|
|
||||||
: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-base">🇺🇸</span>
|
|
||||||
<span className="text-sm">English</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<motion.button
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
className="md:hidden"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
>
|
|
||||||
{mobileMenuOpen ? (
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<Menu className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={
|
|
||||||
mobileMenuOpen
|
|
||||||
? { height: 'auto', opacity: 1 }
|
|
||||||
: { height: 0, opacity: 0 }
|
|
||||||
}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="md:hidden overflow-hidden"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-dark-gray)',
|
|
||||||
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-4 space-y-3">
|
|
||||||
{/* New Navigation Tabs */}
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'移动端 实时 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('competition')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'competition'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'competition' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('realtimeNav', language)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href="/competition"
|
|
||||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'competition'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'competition' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('realtimeNav', language)}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{/* Only show 配置 and 看板 when logged in */}
|
|
||||||
{isLoggedIn && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'移动端 配置 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('traders')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'traders'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'traders' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('configNav', language)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'移动端 看板 button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('trader')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'trader'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'trader' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('dashboardNav', language)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
console.log(
|
|
||||||
'移动端 FAQ button clicked, onPageChange:',
|
|
||||||
onPageChange
|
|
||||||
)
|
|
||||||
onPageChange?.('faq')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentPage === 'faq'
|
|
||||||
? 'var(--brand-yellow)'
|
|
||||||
: 'var(--brand-light-gray)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background for selected state */}
|
|
||||||
{currentPage === 'faq' && (
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(240, 185, 11, 0.15)',
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{t('faqNav', language)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Original Navigation Items - Only on home page */}
|
|
||||||
{isHomePage &&
|
|
||||||
[
|
|
||||||
{ key: 'features', label: t('features', language) },
|
|
||||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
|
||||||
{ key: 'GitHub', label: 'GitHub' },
|
|
||||||
{ key: 'community', label: t('community', language) },
|
|
||||||
].map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.key}
|
|
||||||
href={
|
|
||||||
item.key === 'GitHub'
|
|
||||||
? 'https://github.com/tinkle-community/nofx'
|
|
||||||
: item.key === 'community'
|
|
||||||
? 'https://t.me/nofx_dev_community'
|
|
||||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
|
||||||
}
|
|
||||||
target={
|
|
||||||
item.key === 'GitHub' || item.key === 'community'
|
|
||||||
? '_blank'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
rel={
|
|
||||||
item.key === 'GitHub' || item.key === 'community'
|
|
||||||
? 'noopener noreferrer'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="block text-sm py-2"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Language Toggle */}
|
|
||||||
<div className="py-2">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('language', language)}:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('zh')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
|
||||||
language === 'zh'
|
|
||||||
? 'bg-yellow-500 text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">🇨🇳</span>
|
|
||||||
<span className="text-sm">中文</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLanguageChange?.('en')
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
|
||||||
language === 'en'
|
|
||||||
? 'bg-yellow-500 text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">🇺🇸</span>
|
|
||||||
<span className="text-sm">English</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User info and logout for mobile when logged in */}
|
|
||||||
{isLoggedIn && user && (
|
|
||||||
<div
|
|
||||||
className="mt-4 pt-4"
|
|
||||||
style={{ borderTop: '1px solid var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
|
||||||
style={{ background: 'var(--panel-bg)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{t('loggedInAs', language)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-sm"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onLogout && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onLogout()
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
|
||||||
style={{
|
|
||||||
background: 'var(--binance-red-bg)',
|
|
||||||
color: 'var(--binance-red)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('exitLogin', language)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
|
||||||
{!isLoggedIn &&
|
|
||||||
currentPage !== 'login' &&
|
|
||||||
currentPage !== 'register' && (
|
|
||||||
<div className="space-y-2 mt-2">
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
|
||||||
style={{
|
|
||||||
color: 'var(--brand-light-gray)',
|
|
||||||
border: '1px solid var(--brand-light-gray)',
|
|
||||||
}}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
{t('signIn', language)}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/register"
|
|
||||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'var(--brand-yellow)',
|
|
||||||
color: 'var(--brand-black)',
|
|
||||||
}}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
{t('signUp', language)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
142
web/src/components/ui/alert-dialog.tsx
Normal file
142
web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--panel-border)] bg-[var(--panel-bg)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-2 text-center sm:text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-center sm:space-x-2 gap-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold text-[var(--text-primary)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-[var(--text-secondary)]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--binance-yellow)] disabled:pointer-events-none disabled:opacity-50 bg-[var(--binance-yellow)] text-black hover:brightness-95 h-10 px-8 min-w-[140px] shadow-[0_10px_30px_rgba(240,185,11,0.35)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--panel-border)] disabled:pointer-events-none disabled:opacity-50 border border-[var(--panel-border)] bg-transparent text-[var(--text-secondary)] hover:bg-white/5 h-10 px-8 min-w-[140px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -200,6 +200,69 @@ body {
|
|||||||
border-bottom: 1px solid var(--panel-border);
|
border-bottom: 1px solid var(--panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sonner (toast) - Binance theme overrides */
|
||||||
|
.sonner-toaster {
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast {
|
||||||
|
background: #0b0e11 !important;
|
||||||
|
border: 1px solid var(--panel-border) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
box-shadow: var(--shadow-lg) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast .sonner-title {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast .sonner-description {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success / Error / Warning tint */
|
||||||
|
.nofx-toast[data-type='success'] {
|
||||||
|
background: #0b0e11 !important;
|
||||||
|
border-color: var(--binance-green) !important;
|
||||||
|
border-left: 3px solid var(--binance-green) !important;
|
||||||
|
}
|
||||||
|
.nofx-toast[data-type='success'] .sonner-title,
|
||||||
|
.nofx-toast[data-type='success'] .sonner-description {
|
||||||
|
color: var(--binance-green) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast[data-type='error'] {
|
||||||
|
background: #0b0e11 !important;
|
||||||
|
border-color: var(--binance-red) !important;
|
||||||
|
border-left: 3px solid var(--binance-red) !important;
|
||||||
|
}
|
||||||
|
.nofx-toast[data-type='error'] .sonner-title,
|
||||||
|
.nofx-toast[data-type='error'] .sonner-description {
|
||||||
|
color: var(--binance-red) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast[data-type='warning'],
|
||||||
|
.nofx-toast[data-type='info'] {
|
||||||
|
background: #0b0e11 !important;
|
||||||
|
border-color: var(--binance-yellow) !important;
|
||||||
|
border-left: 3px solid var(--binance-yellow) !important;
|
||||||
|
}
|
||||||
|
.nofx-toast[data-type='warning'] .sonner-title,
|
||||||
|
.nofx-toast[data-type='warning'] .sonner-description,
|
||||||
|
.nofx-toast[data-type='info'] .sonner-title,
|
||||||
|
.nofx-toast[data-type='info'] .sonner-description {
|
||||||
|
color: var(--binance-yellow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nofx-toast .sonner-close-button {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
.nofx-toast .sonner-close-button:hover {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Monospace numbers */
|
/* Monospace numbers */
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||||
@@ -235,6 +298,113 @@ button:disabled {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
width: min(320px, 85vw);
|
||||||
|
background: rgba(11, 14, 17, 0.9);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__header small {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__label select,
|
||||||
|
.dev-toast-controller__label input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__actions button {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__actions button:first-child {
|
||||||
|
background: rgba(240, 185, 11, 0.15);
|
||||||
|
color: var(--binance-yellow);
|
||||||
|
border: 1px solid rgba(240, 185, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__actions button:last-child {
|
||||||
|
background: rgba(132, 142, 156, 0.15);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-toast-controller__actions button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-custom-toast {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #f0b90b, #df8c0c);
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-custom-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-custom-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.binance-card:hover {
|
.binance-card:hover {
|
||||||
border-color: var(--panel-border-hover);
|
border-color: var(--panel-border-hover);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
|||||||
56
web/src/layouts/AuthLayout.tsx
Normal file
56
web/src/layouts/AuthLayout.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Outlet, Link } from 'react-router-dom'
|
||||||
|
import { Container } from '../components/Container'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
|
interface AuthLayoutProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
|
const { language, setLanguage } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
||||||
|
{/* Simple Header with Logo and Language Selector */}
|
||||||
|
<nav
|
||||||
|
className="fixed top-0 w-full z-50"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(11, 14, 17, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||||
|
<span className="text-xl font-bold" style={{ color: '#F0B90B' }}>
|
||||||
|
NOFX
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Language Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
|
||||||
|
className="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: '#1E2329',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language === 'zh' ? 'English' : '中文'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content with top padding to avoid overlap with fixed header */}
|
||||||
|
<div className="pt-16">{children || <Outlet />}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
web/src/layouts/MainLayout.tsx
Normal file
97
web/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import HeaderBar from '../components/HeaderBar'
|
||||||
|
import { Container } from '../components/Container'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { t } from '../i18n/translations'
|
||||||
|
|
||||||
|
interface MainLayoutProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: MainLayoutProps) {
|
||||||
|
const { language, setLanguage } = useLanguage()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// 根据路径自动判断当前页面
|
||||||
|
const getCurrentPage = (): 'competition' | 'traders' | 'trader' | 'faq' => {
|
||||||
|
if (location.pathname === '/faq') return 'faq'
|
||||||
|
if (location.pathname === '/traders') return 'traders'
|
||||||
|
if (location.pathname === '/dashboard') return 'trader'
|
||||||
|
if (location.pathname === '/competition') return 'competition'
|
||||||
|
return 'competition' // 默认
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen"
|
||||||
|
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
<HeaderBar
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
currentPage={getCurrentPage()}
|
||||||
|
language={language}
|
||||||
|
onLanguageChange={setLanguage}
|
||||||
|
user={user}
|
||||||
|
onLogout={logout}
|
||||||
|
onPageChange={() => {
|
||||||
|
// React Router handles navigation now
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Container as="main" className="py-6 pt-24">
|
||||||
|
{children || <Outlet />}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer
|
||||||
|
className="mt-16"
|
||||||
|
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
className="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">
|
||||||
|
<a
|
||||||
|
href="https://github.com/tinkle-community/nofx"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
web/src/lib/clipboard.ts
Normal file
30
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { notify } from './notify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本到剪贴板,并显示轻量提示。
|
||||||
|
*/
|
||||||
|
export async function copyWithToast(text: string, successMsg = '已复制') {
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} else {
|
||||||
|
// 兼容降级:创建临时文本域执行复制
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.style.position = 'fixed'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
notify.success(successMsg)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Clipboard copy failed:', err)
|
||||||
|
notify.error('复制失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { copyWithToast }
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
* - Automatic redirect to login page
|
* - Automatic redirect to login page
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export class HttpClient {
|
export class HttpClient {
|
||||||
// Singleton flag to prevent duplicate 401 handling
|
// Singleton flag to prevent duplicate 401 handling
|
||||||
private static isHandling401 = false
|
private static isHandling401 = false
|
||||||
@@ -23,52 +25,7 @@ export class HttpClient {
|
|||||||
* Show login required notification to user
|
* Show login required notification to user
|
||||||
*/
|
*/
|
||||||
private showLoginRequiredNotification(): void {
|
private showLoginRequiredNotification(): void {
|
||||||
// Create notification element
|
toast.warning('登录已过期,请先登录', { duration: 1800 })
|
||||||
const notification = document.createElement('div')
|
|
||||||
notification.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: linear-gradient(135deg, #F0B90B 0%, #FCD535 100%);
|
|
||||||
color: #0B0E11;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
z-index: 10000;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
animation: slideDown 0.3s ease-out;
|
|
||||||
`
|
|
||||||
notification.textContent = '⚠️ 登录已过期,请先登录'
|
|
||||||
|
|
||||||
// Add slide down animation
|
|
||||||
const style = document.createElement('style')
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
|
|
||||||
// Add to page
|
|
||||||
document.body.appendChild(notification)
|
|
||||||
|
|
||||||
// Auto remove after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.style.animation = 'slideDown 0.3s ease-out reverse'
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(notification)
|
|
||||||
document.head.removeChild(style)
|
|
||||||
}, 300)
|
|
||||||
}, 1800)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
87
web/src/lib/notify.tsx
Normal file
87
web/src/lib/notify.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
okText?: string
|
||||||
|
cancelText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局 confirm 函数的引用,将在 ConfirmDialogProvider 中设置
|
||||||
|
let globalConfirm:
|
||||||
|
| ((options: ConfirmOptions & { message: string }) => Promise<boolean>)
|
||||||
|
| null = null
|
||||||
|
|
||||||
|
export function setGlobalConfirm(
|
||||||
|
confirmFn: (options: ConfirmOptions & { message: string }) => Promise<boolean>
|
||||||
|
) {
|
||||||
|
globalConfirm = confirmFn
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认对话框函数,使用 shadcn AlertDialog
|
||||||
|
export function confirmToast(
|
||||||
|
message: string,
|
||||||
|
options: ConfirmOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!globalConfirm) {
|
||||||
|
console.error('ConfirmDialogProvider not initialized')
|
||||||
|
return Promise.resolve(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalConfirm({
|
||||||
|
message,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一通知封装,避免组件直接依赖 sonner
|
||||||
|
type Message = string | ReactNode
|
||||||
|
|
||||||
|
function message(msg: Message, options?: Parameters<typeof toast>[1]) {
|
||||||
|
return toast(msg as any, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(msg: Message, options?: Parameters<typeof toast.success>[1]) {
|
||||||
|
return toast.success(msg as any, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(msg: Message, options?: Parameters<typeof toast.error>[1]) {
|
||||||
|
return toast.error(msg as any, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(msg: Message, options?: Parameters<typeof toast.info>[1]) {
|
||||||
|
return toast.info?.(msg as any, options) ?? toast(msg as any, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function warning(msg: Message, options?: Parameters<typeof toast.warning>[1]) {
|
||||||
|
return toast.warning?.(msg as any, options) ?? toast(msg as any, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function custom(
|
||||||
|
renderer: Parameters<typeof toast.custom>[0],
|
||||||
|
options?: Parameters<typeof toast.custom>[1]
|
||||||
|
) {
|
||||||
|
return toast.custom(renderer, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id?: string | number) {
|
||||||
|
return toast.dismiss(id as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
function promise<T>(p: Promise<T> | (() => Promise<T>), msgs: any) {
|
||||||
|
return toast.promise<T>(p as any, msgs as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notify = {
|
||||||
|
message,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
custom,
|
||||||
|
dismiss,
|
||||||
|
promise,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { confirmToast, notify }
|
||||||
28
web/src/lib/text.ts
Normal file
28
web/src/lib/text.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 文本工具
|
||||||
|
*
|
||||||
|
* stripLeadingIcons: 去掉翻译文案或标题前面用于装饰的 Emoji/符号,
|
||||||
|
* 以便在组件里自行放置图标时不重复显示。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去掉开头的装饰性 Emoji/符号以及随后的分隔符(空格/冒号/点号等)。
|
||||||
|
*/
|
||||||
|
export function stripLeadingIcons(input: string | undefined | null): string {
|
||||||
|
if (!input) return ''
|
||||||
|
let s = String(input)
|
||||||
|
|
||||||
|
// 1) 去除常见的 Emoji/符号块(箭头、杂项符号、几何图形、表情等)
|
||||||
|
// 覆盖常见范围,兼容性好于使用 Unicode 属性类。
|
||||||
|
s = s.replace(
|
||||||
|
/^[\s\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u25A0-\u25FF\u2600-\u27BF\u2B00-\u2BFF\u1F000-\u1FAFF]+/u,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2) 去掉开头可能残留的分隔符(空格、连字符、冒号、居中点等)
|
||||||
|
s = s.replace(/^[\s\-:•·]+/, '')
|
||||||
|
|
||||||
|
return s.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { stripLeadingIcons }
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<Toaster
|
||||||
|
theme="dark"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
position="top-center"
|
||||||
|
duration={2200}
|
||||||
|
toastOptions={{
|
||||||
|
className: 'nofx-toast',
|
||||||
|
style: {
|
||||||
|
background: '#0b0e11',
|
||||||
|
border: '1px solid var(--panel-border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import HeaderBar from '../components/landing/HeaderBar'
|
|
||||||
import { FAQLayout } from '../components/faq/FAQLayout'
|
import { FAQLayout } from '../components/faq/FAQLayout'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
|
||||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
|
||||||
import { t } from '../i18n/translations'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FAQ 页面
|
* FAQ 页面
|
||||||
*
|
*
|
||||||
* 这个页面只是组件的集合,负责:
|
* HeaderBar 和 Footer 现在由 MainLayout 提供
|
||||||
* - 组装 HeaderBar 和 FAQLayout
|
|
||||||
* - 提供全局状态(语言、用户、系统配置)
|
|
||||||
* - 处理页面级别的导航
|
|
||||||
*
|
*
|
||||||
* 所有 FAQ 相关的逻辑都在子组件中:
|
* 所有 FAQ 相关的逻辑都在子组件中:
|
||||||
* - FAQLayout: 整体布局和搜索逻辑
|
* - FAQLayout: 整体布局和搜索逻辑
|
||||||
@@ -22,54 +15,7 @@ import { t } from '../i18n/translations'
|
|||||||
* FAQ 数据配置在 data/faqData.ts
|
* FAQ 数据配置在 data/faqData.ts
|
||||||
*/
|
*/
|
||||||
export function FAQPage() {
|
export function FAQPage() {
|
||||||
const { language, setLanguage } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { user, logout } = useAuth()
|
|
||||||
useSystemConfig() // Load system config but don't use it
|
|
||||||
|
|
||||||
return (
|
return <FAQLayout language={language} />
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: '#000000', color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
currentPage="faq"
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onPageChange={(page) => {
|
|
||||||
if (page === 'competition') {
|
|
||||||
window.history.pushState({}, '', '/competition')
|
|
||||||
window.location.href = '/competition'
|
|
||||||
} else if (page === 'traders') {
|
|
||||||
window.history.pushState({}, '', '/traders')
|
|
||||||
window.location.href = '/traders'
|
|
||||||
} else if (page === 'trader') {
|
|
||||||
window.history.pushState({}, '', '/dashboard')
|
|
||||||
window.location.href = '/dashboard'
|
|
||||||
} else if (page === 'faq') {
|
|
||||||
window.history.pushState({}, '', '/faq')
|
|
||||||
window.location.href = '/faq'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FAQLayout language={language} />
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer
|
|
||||||
className="mt-16"
|
|
||||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="max-w-7xl 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>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ArrowRight } from 'lucide-react'
|
import { ArrowRight } from 'lucide-react'
|
||||||
import HeaderBar from '../components/landing/HeaderBar'
|
import HeaderBar from '../components/HeaderBar'
|
||||||
import HeroSection from '../components/landing/HeroSection'
|
import HeroSection from '../components/landing/HeroSection'
|
||||||
import AboutSection from '../components/landing/AboutSection'
|
import AboutSection from '../components/landing/AboutSection'
|
||||||
import FeaturesSection from '../components/landing/FeaturesSection'
|
import FeaturesSection from '../components/landing/FeaturesSection'
|
||||||
|
|||||||
942
web/src/pages/TraderDashboard.tsx
Normal file
942
web/src/pages/TraderDashboard.tsx
Normal file
@@ -0,0 +1,942 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { EquityChart } from '../components/EquityChart'
|
||||||
|
import AILearning from '../components/AILearning'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { t, type Language } from '../i18n/translations'
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
Brain,
|
||||||
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Inbox,
|
||||||
|
Send,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { stripLeadingIcons } from '../lib/text'
|
||||||
|
import type {
|
||||||
|
SystemStatus,
|
||||||
|
AccountInfo,
|
||||||
|
Position,
|
||||||
|
DecisionRecord,
|
||||||
|
Statistics,
|
||||||
|
TraderInfo,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
// 获取友好的AI模型名称
|
||||||
|
function getModelDisplayName(modelId: string): string {
|
||||||
|
switch (modelId.toLowerCase()) {
|
||||||
|
case 'deepseek':
|
||||||
|
return 'DeepSeek'
|
||||||
|
case 'qwen':
|
||||||
|
return 'Qwen'
|
||||||
|
case 'claude':
|
||||||
|
return 'Claude'
|
||||||
|
default:
|
||||||
|
return modelId.toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TraderDashboard() {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const { user, token } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>(
|
||||||
|
searchParams.get('trader') || undefined
|
||||||
|
)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||||
|
|
||||||
|
// 获取trader列表(仅在用户登录时)
|
||||||
|
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||||
|
user && token ? 'traders' : null,
|
||||||
|
api.getTraders,
|
||||||
|
{
|
||||||
|
refreshInterval: 10000,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当获取到traders后,设置默认选中第一个
|
||||||
|
useEffect(() => {
|
||||||
|
if (traders && traders.length > 0 && !selectedTraderId) {
|
||||||
|
const firstTraderId = traders[0].trader_id
|
||||||
|
setSelectedTraderId(firstTraderId)
|
||||||
|
setSearchParams({ trader: firstTraderId })
|
||||||
|
}
|
||||||
|
}, [traders, selectedTraderId, setSearchParams])
|
||||||
|
|
||||||
|
// 更新URL参数
|
||||||
|
const handleTraderSelect = (traderId: string) => {
|
||||||
|
setSelectedTraderId(traderId)
|
||||||
|
setSearchParams({ trader: traderId })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在trader页面,获取该trader的数据
|
||||||
|
const { data: status } = useSWR<SystemStatus>(
|
||||||
|
selectedTraderId ? `status-${selectedTraderId}` : null,
|
||||||
|
() => api.getStatus(selectedTraderId),
|
||||||
|
{
|
||||||
|
refreshInterval: 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: account } = useSWR<AccountInfo>(
|
||||||
|
selectedTraderId ? `account-${selectedTraderId}` : null,
|
||||||
|
() => api.getAccount(selectedTraderId),
|
||||||
|
{
|
||||||
|
refreshInterval: 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: positions } = useSWR<Position[]>(
|
||||||
|
selectedTraderId ? `positions-${selectedTraderId}` : null,
|
||||||
|
() => api.getPositions(selectedTraderId),
|
||||||
|
{
|
||||||
|
refreshInterval: 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||||||
|
selectedTraderId ? `decisions/latest-${selectedTraderId}` : null,
|
||||||
|
() => api.getLatestDecisions(selectedTraderId),
|
||||||
|
{
|
||||||
|
refreshInterval: 30000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 20000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: stats } = useSWR<Statistics>(
|
||||||
|
selectedTraderId ? `statistics-${selectedTraderId}` : null,
|
||||||
|
() => api.getStatistics(selectedTraderId),
|
||||||
|
{
|
||||||
|
refreshInterval: 30000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 20000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Avoid unused variable warning
|
||||||
|
void stats
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
const now = new Date().toLocaleTimeString()
|
||||||
|
setLastUpdate(now)
|
||||||
|
}
|
||||||
|
}, [account])
|
||||||
|
|
||||||
|
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
||||||
|
|
||||||
|
// If API failed with error, show empty state
|
||||||
|
if (tradersError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center max-w-md mx-auto px-6">
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.1)',
|
||||||
|
border: '2px solid rgba(240, 185, 11, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('dashboardEmptyTitle', language)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-base mb-6" style={{ color: '#848E9C' }}>
|
||||||
|
{t('dashboardEmptyDescription', language)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/traders')}
|
||||||
|
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||||
|
color: '#0B0E11',
|
||||||
|
boxShadow: '0 4px 12px rgba(240, 185, 11, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('goToTradersPage', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If traders is loaded and empty, show empty state
|
||||||
|
if (traders && traders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="text-center max-w-md mx-auto px-6">
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.1)',
|
||||||
|
border: '2px solid rgba(240, 185, 11, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('dashboardEmptyTitle', language)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-base mb-6" style={{ color: '#848E9C' }}>
|
||||||
|
{t('dashboardEmptyDescription', language)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/traders')}
|
||||||
|
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||||
|
color: '#0B0E11',
|
||||||
|
boxShadow: '0 4px 12px rgba(240, 185, 11, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('goToTradersPage', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If traders is still loading or selectedTrader is not ready, show skeleton
|
||||||
|
if (!selectedTrader) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="binance-card p-6 animate-pulse">
|
||||||
|
<div className="skeleton h-8 w-48 mb-3"></div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="skeleton h-4 w-32"></div>
|
||||||
|
<div className="skeleton h-4 w-24"></div>
|
||||||
|
<div className="skeleton h-4 w-28"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="binance-card p-5 animate-pulse">
|
||||||
|
<div className="skeleton h-4 w-24 mb-3"></div>
|
||||||
|
<div className="skeleton h-8 w-32"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="binance-card p-6 animate-pulse">
|
||||||
|
<div className="skeleton h-6 w-40 mb-4"></div>
|
||||||
|
<div className="skeleton h-64 w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Trader Header */}
|
||||||
|
<div
|
||||||
|
className="mb-6 rounded p-6 animate-scale-in"
|
||||||
|
style={{
|
||||||
|
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)',
|
||||||
|
boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold flex items-center gap-2"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bot className="w-5 h-5" style={{ color: '#0B0E11' }} />
|
||||||
|
</span>
|
||||||
|
{selectedTrader.trader_name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Trader Selector */}
|
||||||
|
{traders && traders.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{t('switchTrader', language)}:
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={selectedTraderId}
|
||||||
|
onChange={(e) => handleTraderSelect(e.target.value)}
|
||||||
|
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
background: '#1E2329',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{traders.map((trader) => (
|
||||||
|
<option key={trader.trader_id} value={trader.trader_id}>
|
||||||
|
{trader.trader_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 text-sm"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
AI Model:{' '}
|
||||||
|
<span
|
||||||
|
className="font-semibold"
|
||||||
|
style={{
|
||||||
|
color: selectedTrader.ai_model.includes('qwen')
|
||||||
|
? '#c084fc'
|
||||||
|
: '#60a5fa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getModelDisplayName(
|
||||||
|
selectedTrader.ai_model.split('_').pop() ||
|
||||||
|
selectedTrader.ai_model
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Cycles: {status.call_count}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Runtime: {status.runtime_minutes} min</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug Info */}
|
||||||
|
{account && (
|
||||||
|
<div
|
||||||
|
className="mb-4 p-3 rounded text-xs font-mono"
|
||||||
|
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||||
|
>
|
||||||
|
<div style={{ color: '#848E9C' }}>
|
||||||
|
<RefreshCw className="inline w-4 h-4 mr-1 align-text-bottom" />
|
||||||
|
Last Update: {lastUpdate} | Total Equity:{' '}
|
||||||
|
{account?.total_equity?.toFixed(2) || '0.00'} | Available:{' '}
|
||||||
|
{account?.available_balance?.toFixed(2) || '0.00'} | P&L:{' '}
|
||||||
|
{account?.total_pnl?.toFixed(2) || '0.00'} (
|
||||||
|
{account?.total_pnl_pct?.toFixed(2) || '0.00'}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatCard
|
||||||
|
title={t('totalEquity', language)}
|
||||||
|
value={`${account?.total_equity?.toFixed(2) || '0.00'} USDT`}
|
||||||
|
change={account?.total_pnl_pct || 0}
|
||||||
|
positive={(account?.total_pnl ?? 0) > 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t('availableBalance', language)}
|
||||||
|
value={`${account?.available_balance?.toFixed(2) || '0.00'} USDT`}
|
||||||
|
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t('totalPnL', language)}
|
||||||
|
value={`${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'} USDT`}
|
||||||
|
change={account?.total_pnl_pct || 0}
|
||||||
|
positive={(account?.total_pnl ?? 0) >= 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t('positions', language)}
|
||||||
|
value={`${account?.position_count || 0}`}
|
||||||
|
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区:左右分屏 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{/* 左侧:图表 + 持仓 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Equity Chart */}
|
||||||
|
<div className="animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||||
|
<EquityChart traderId={selectedTrader.trader_id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Positions */}
|
||||||
|
<div
|
||||||
|
className="binance-card p-6 animate-slide-in"
|
||||||
|
style={{ animationDelay: '0.15s' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold flex items-center gap-2"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||||
|
{t('currentPositions', language)}
|
||||||
|
</h2>
|
||||||
|
{positions && positions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="text-xs px-3 py-1 rounded"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.1)',
|
||||||
|
color: '#F0B90B',
|
||||||
|
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{positions.length} {t('active', language)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{positions && positions.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left border-b border-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('symbol', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('side', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('entryPrice', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('markPrice', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('quantity', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('positionValue', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('leverage', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('unrealizedPnL', language)}
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-semibold text-gray-400">
|
||||||
|
{t('liqPrice', language)}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{positions.map((pos, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="border-b border-gray-800 last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-3 font-mono font-semibold">
|
||||||
|
{pos.symbol}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 rounded text-xs font-bold"
|
||||||
|
style={
|
||||||
|
pos.side === 'long'
|
||||||
|
? {
|
||||||
|
background: 'rgba(14, 203, 129, 0.1)',
|
||||||
|
color: '#0ECB81',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: 'rgba(246, 70, 93, 0.1)',
|
||||||
|
color: '#F6465D',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
pos.side === 'long' ? 'long' : 'short',
|
||||||
|
language
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{pos.entry_price.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{pos.mark_price.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{pos.quantity.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono font-bold"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{(pos.quantity * pos.mark_price).toFixed(2)} USDT
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
>
|
||||||
|
{pos.leverage}x
|
||||||
|
</td>
|
||||||
|
<td className="py-3 font-mono">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||||
|
{pos.unrealized_pnl.toFixed(2)} (
|
||||||
|
{pos.unrealized_pnl_pct.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-3 font-mono"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{pos.liquidation_price.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||||
|
<div className="mb-4 opacity-50 flex justify-center">
|
||||||
|
<PieChart className="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold mb-2">
|
||||||
|
{t('noPositions', language)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t('noActivePositions', language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:Recent Decisions */}
|
||||||
|
<div
|
||||||
|
className="binance-card p-6 animate-slide-in h-fit lg:sticky lg:top-24 lg:max-h-[calc(100vh-120px)]"
|
||||||
|
style={{ animationDelay: '0.2s' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-5 pb-4 border-b"
|
||||||
|
style={{ borderColor: '#2B3139' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%)',
|
||||||
|
boxShadow: '0 4px 14px rgba(99, 102, 241, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Brain className="w-5 h-5" style={{ color: '#FFFFFF' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('recentDecisions', language)}
|
||||||
|
</h2>
|
||||||
|
{decisions && decisions.length > 0 && (
|
||||||
|
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
|
{t('lastCycles', language, { count: decisions.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="space-y-4 overflow-y-auto pr-2"
|
||||||
|
style={{ maxHeight: 'calc(100vh - 280px)' }}
|
||||||
|
>
|
||||||
|
{decisions && decisions.length > 0 ? (
|
||||||
|
decisions.map((decision, i) => (
|
||||||
|
<DecisionCard key={i} decision={decision} language={language} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="mb-4 opacity-30 flex justify-center">
|
||||||
|
<Brain className="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-lg font-semibold mb-2"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{t('noDecisionsYet', language)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{t('aiDecisionsWillAppear', language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Learning & Performance Analysis */}
|
||||||
|
<div className="mb-6 animate-slide-in" style={{ animationDelay: '0.3s' }}>
|
||||||
|
<AILearning traderId={selectedTrader.trader_id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat Card Component
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
positive,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
change?: number
|
||||||
|
positive?: boolean
|
||||||
|
subtitle?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stat-card animate-fade-in">
|
||||||
|
<div
|
||||||
|
className="text-xs mb-2 mono uppercase tracking-wider"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-bold mb-1 mono"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{change !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="text-sm mono font-bold"
|
||||||
|
style={{ color: positive ? '#0ECB81' : '#F6465D' }}
|
||||||
|
>
|
||||||
|
{positive ? '▲' : '▼'} {positive ? '+' : ''}
|
||||||
|
{change.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs mt-2 mono" style={{ color: '#848E9C' }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision Card Component
|
||||||
|
function DecisionCard({
|
||||||
|
decision,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
decision: DecisionRecord
|
||||||
|
language: Language
|
||||||
|
}) {
|
||||||
|
const [showInputPrompt, setShowInputPrompt] = useState(false)
|
||||||
|
const [showCoT, setShowCoT] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded p-5 transition-all duration-300 hover:translate-y-[-2px]"
|
||||||
|
style={{
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
background: '#1E2329',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('cycle', language)} #{decision.cycle_number}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
|
{new Date(decision.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-3 py-1 rounded text-xs font-bold"
|
||||||
|
style={
|
||||||
|
decision.success
|
||||||
|
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||||
|
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(decision.success ? 'success' : 'failed', language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Prompt - Collapsible */}
|
||||||
|
{decision.input_prompt && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInputPrompt(!showInputPrompt)}
|
||||||
|
className="flex items-center gap-2 text-sm transition-colors"
|
||||||
|
style={{ color: '#60a5fa' }}
|
||||||
|
>
|
||||||
|
<span className="font-semibold flex items-center gap-2">
|
||||||
|
<Inbox className="w-4 h-4" /> {t('inputPrompt', language)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{showInputPrompt
|
||||||
|
? t('collapse', language)
|
||||||
|
: t('expand', language)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showInputPrompt && (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
background: '#0B0E11',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{decision.input_prompt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Chain of Thought - Collapsible */}
|
||||||
|
{decision.cot_trace && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCoT(!showCoT)}
|
||||||
|
className="flex items-center gap-2 text-sm transition-colors"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
>
|
||||||
|
<span className="font-semibold flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4" />{' '}
|
||||||
|
{stripLeadingIcons(t('aiThinking', language))}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{showCoT ? t('collapse', language) : t('expand', language)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showCoT && (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
background: '#0B0E11',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{decision.cot_trace}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decisions Actions */}
|
||||||
|
{decision.decisions && decision.decisions.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{decision.decisions.map((action, j) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className="flex items-center gap-2 text-sm rounded px-3 py-2"
|
||||||
|
style={{ background: '#0B0E11' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono font-bold"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
>
|
||||||
|
{action.symbol}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-bold"
|
||||||
|
style={
|
||||||
|
action.action.includes('open')
|
||||||
|
? {
|
||||||
|
background: 'rgba(96, 165, 250, 0.1)',
|
||||||
|
color: '#60a5fa',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: 'rgba(240, 185, 11, 0.1)',
|
||||||
|
color: '#F0B90B',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{action.action}
|
||||||
|
</span>
|
||||||
|
{action.leverage > 0 && (
|
||||||
|
<span style={{ color: '#F0B90B' }}>{action.leverage}x</span>
|
||||||
|
)}
|
||||||
|
{action.price > 0 && (
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
@{action.price.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: action.success ? '#0ECB81' : '#F6465D' }}>
|
||||||
|
{action.success ? (
|
||||||
|
<Check className="w-3 h-3 inline" />
|
||||||
|
) : (
|
||||||
|
<X className="w-3 h-3 inline" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{action.error && (
|
||||||
|
<span className="text-xs ml-2" style={{ color: '#F6465D' }}>
|
||||||
|
{action.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account State Summary */}
|
||||||
|
{decision.account_state && (
|
||||||
|
<div
|
||||||
|
className="flex gap-4 text-xs mb-3 rounded px-3 py-2"
|
||||||
|
style={{ background: '#0B0E11', color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
净值: {decision.account_state.total_balance.toFixed(2)} USDT
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
可用: {decision.account_state.available_balance.toFixed(2)} USDT
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
保证金率: {decision.account_state.margin_used_pct.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span>持仓: {decision.account_state.position_count}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
decision.candidate_coins &&
|
||||||
|
decision.candidate_coins.length === 0
|
||||||
|
? '#F6465D'
|
||||||
|
: '#848E9C',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('candidateCoins', language)}:{' '}
|
||||||
|
{decision.candidate_coins?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Candidate Coins Warning */}
|
||||||
|
{decision.candidate_coins && decision.candidate_coins.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded px-4 py-3 mb-3 flex items-start gap-3"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(246, 70, 93, 0.1)',
|
||||||
|
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||||
|
color: '#F6465D',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold mb-1">
|
||||||
|
{t('candidateCoinsZeroWarning', language)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||||
|
<div>{t('possibleReasons', language)}</div>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||||
|
<li>{t('coinPoolApiNotConfigured', language)}</li>
|
||||||
|
<li>{t('apiConnectionTimeout', language)}</li>
|
||||||
|
<li>{t('noCustomCoinsAndApiFailed', language)}</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2">
|
||||||
|
<strong>{t('solutions', language)}</strong>
|
||||||
|
</div>
|
||||||
|
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||||
|
<li>{t('setCustomCoinsInConfig', language)}</li>
|
||||||
|
<li>{t('orConfigureCorrectApiUrl', language)}</li>
|
||||||
|
<li>{t('orDisableCoinPoolOptions', language)}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution Logs */}
|
||||||
|
{decision.execution_log && decision.execution_log.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{decision.execution_log.map((log, k) => (
|
||||||
|
<div
|
||||||
|
key={k}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
log.includes('✓') || log.includes('成功')
|
||||||
|
? '#0ECB81'
|
||||||
|
: '#F6465D',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{decision.error_message && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded px-3 py-2 mt-3 flex items-center gap-2"
|
||||||
|
style={{ color: '#F6465D', background: 'rgba(246, 70, 93, 0.1)' }}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" /> {decision.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
web/src/routes/index.tsx
Normal file
62
web/src/routes/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
|
import MainLayout from '../layouts/MainLayout'
|
||||||
|
import AuthLayout from '../layouts/AuthLayout'
|
||||||
|
import { LandingPage } from '../pages/LandingPage'
|
||||||
|
import { FAQPage } from '../pages/FAQPage'
|
||||||
|
import { LoginPage } from '../components/LoginPage'
|
||||||
|
import { RegisterPage } from '../components/RegisterPage'
|
||||||
|
import { ResetPasswordPage } from '../components/ResetPasswordPage'
|
||||||
|
import { CompetitionPage } from '../components/CompetitionPage'
|
||||||
|
import { AITradersPage } from '../components/AITradersPage'
|
||||||
|
import TraderDashboard from '../pages/TraderDashboard'
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <LandingPage />,
|
||||||
|
},
|
||||||
|
// Auth routes - using AuthLayout
|
||||||
|
{
|
||||||
|
element: <AuthLayout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
element: <RegisterPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password',
|
||||||
|
element: <ResetPasswordPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Main app routes - using MainLayout with nested routes
|
||||||
|
{
|
||||||
|
element: <MainLayout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/faq',
|
||||||
|
element: <FAQPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/competition',
|
||||||
|
element: <CompetitionPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/traders',
|
||||||
|
element: <AITradersPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
element: <TraderDashboard />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <Navigate to="/" replace />,
|
||||||
|
},
|
||||||
|
])
|
||||||
Reference in New Issue
Block a user