mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -23,7 +24,9 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-password-checklist": "^1.8.1",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^1.5.0",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"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 { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { stripLeadingIcons } from '../lib/text'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
Brain,
|
||||
@@ -78,7 +79,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
className="rounded p-6"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -695,7 +698,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
style={{ color: '#E0E7FF' }}
|
||||
>
|
||||
<BarChart3 className="w-5 h-5" />{' '}
|
||||
{t('symbolPerformance', language)}
|
||||
{stripLeadingIcons(t('symbolPerformance', language))}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
@@ -1084,7 +1087,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
className="font-bold mb-3 text-base"
|
||||
style={{ color: '#FCD34D' }}
|
||||
>
|
||||
{t('howAILearns', language)}
|
||||
{stripLeadingIcons(t('howAILearns', language))}
|
||||
</h3>
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import type {
|
||||
@@ -29,7 +30,10 @@ import {
|
||||
BookOpen,
|
||||
HelpCircle,
|
||||
Radio,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// 获取友好的AI模型名称
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
@@ -58,6 +62,7 @@ interface AITradersPageProps {
|
||||
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const { language } = useLanguage()
|
||||
const { user, token } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = 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)
|
||||
|
||||
if (!model?.enabled) {
|
||||
alert(t('modelNotConfigured', language))
|
||||
toast.error(t('modelNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchange?.enabled) {
|
||||
alert(t('exchangeNotConfigured', language))
|
||||
toast.error(t('exchangeNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
await api.createTrader(data)
|
||||
await toast.promise(api.createTrader(data), {
|
||||
loading: '正在创建…',
|
||||
success: '创建成功',
|
||||
error: '创建失败',
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
mutateTraders()
|
||||
} catch (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)
|
||||
} catch (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)
|
||||
|
||||
if (!model) {
|
||||
alert(t('modelConfigNotExist', language))
|
||||
toast.error(t('modelConfigNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchange) {
|
||||
alert(t('exchangeConfigNotExist', language))
|
||||
toast.error(t('exchangeConfigNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,39 +291,58 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
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)
|
||||
setEditingTrader(null)
|
||||
mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to update trader:', error)
|
||||
alert(t('updateTraderFailed', language))
|
||||
toast.error(t('updateTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTrader = async (traderId: string) => {
|
||||
if (!confirm(t('confirmDeleteTrader', language))) return
|
||||
{
|
||||
const ok = await confirmToast(t('confirmDeleteTrader', language))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteTrader(traderId)
|
||||
await toast.promise(api.deleteTrader(traderId), {
|
||||
loading: '正在删除…',
|
||||
success: '删除成功',
|
||||
error: '删除失败',
|
||||
})
|
||||
mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete trader:', error)
|
||||
alert(t('deleteTraderFailed', language))
|
||||
toast.error(t('deleteTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTrader = async (traderId: string, running: boolean) => {
|
||||
try {
|
||||
if (running) {
|
||||
await api.stopTrader(traderId)
|
||||
await toast.promise(api.stopTrader(traderId), {
|
||||
loading: '正在停止…',
|
||||
success: '已停止',
|
||||
error: '停止失败',
|
||||
})
|
||||
} else {
|
||||
await api.startTrader(traderId)
|
||||
await toast.promise(api.startTrader(traderId), {
|
||||
loading: '正在启动…',
|
||||
success: '已启动',
|
||||
error: '启动失败',
|
||||
})
|
||||
}
|
||||
mutateTraders()
|
||||
} catch (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)) {
|
||||
const usingTraders = config.getUsingTraders(config.id)
|
||||
const traderNames = usingTraders.map((t) => t.trader_name).join(', ')
|
||||
alert(
|
||||
t(config.cannotDeleteKey, language) +
|
||||
'\n\n' +
|
||||
t('tradersUsing', language) +
|
||||
': ' +
|
||||
traderNames +
|
||||
'\n\n' +
|
||||
t('pleaseDeleteTradersFirst', language)
|
||||
toast.error(
|
||||
`${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(t(config.confirmDeleteKey, language))) return
|
||||
{
|
||||
const ok = await confirmToast(t(config.confirmDeleteKey, language))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedItems =
|
||||
@@ -374,7 +399,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
) || []
|
||||
|
||||
const request = config.buildRequest(updatedItems)
|
||||
await config.updateApi(request)
|
||||
await toast.promise(config.updateApi(request), {
|
||||
loading: '正在更新配置…',
|
||||
success: '配置已更新',
|
||||
error: '更新配置失败',
|
||||
})
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedItems = await config.refreshApi()
|
||||
@@ -383,7 +412,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
config.closeModal()
|
||||
} catch (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 =
|
||||
existingModel || supportedModels?.find((m) => m.id === modelId)
|
||||
if (!modelToUpdate) {
|
||||
alert(t('modelNotExist', language))
|
||||
toast.error(t('modelNotExist', language))
|
||||
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()
|
||||
@@ -499,7 +532,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setEditingModel(null)
|
||||
} catch (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
|
||||
)
|
||||
if (!exchangeToUpdate) {
|
||||
alert(t('exchangeNotExist', language))
|
||||
toast.error(t('exchangeNotExist', language))
|
||||
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()
|
||||
@@ -639,7 +676,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setEditingExchange(null)
|
||||
} catch (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
|
||||
) => {
|
||||
try {
|
||||
await api.saveUserSignalSource(coinPoolUrl, oiTopUrl)
|
||||
await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
error: '保存失败',
|
||||
})
|
||||
setUserSignalSource({ coinPoolUrl, oiTopUrl })
|
||||
setShowSignalSourceModal(false)
|
||||
} catch (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">
|
||||
{/* Status */}
|
||||
<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)}
|
||||
</div>
|
||||
</div> */}
|
||||
<div
|
||||
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${
|
||||
trader.is_running
|
||||
@@ -1052,10 +1093,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1.5 md:gap-2 flex-wrap md:flex-nowrap">
|
||||
{/* Actions: 禁止换行,超出横向滚动 */}
|
||||
<div className="flex gap-1.5 md:gap-2 flex-nowrap overflow-x-auto items-center">
|
||||
<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"
|
||||
style={{
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
@@ -1069,7 +1116,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
<button
|
||||
onClick={() => handleEditTrader(trader.trader_id)}
|
||||
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={{
|
||||
background: trader.is_running
|
||||
? 'rgba(132, 142, 156, 0.1)'
|
||||
@@ -1077,7 +1124,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
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
|
||||
@@ -1788,6 +1836,7 @@ function ExchangeConfigModal({
|
||||
await navigator.clipboard.writeText(ip)
|
||||
setCopiedIP(true)
|
||||
setTimeout(() => setCopiedIP(false), 2000)
|
||||
toast.success(t('ipCopied', language))
|
||||
} else {
|
||||
// 降级方案: 使用传统的 execCommand 方法
|
||||
const textArea = document.createElement('textarea')
|
||||
@@ -1804,6 +1853,7 @@ function ExchangeConfigModal({
|
||||
if (successful) {
|
||||
setCopiedIP(true)
|
||||
setTimeout(() => setCopiedIP(false), 2000)
|
||||
toast.success(t('ipCopied', language))
|
||||
} else {
|
||||
throw new Error('复制命令执行失败')
|
||||
}
|
||||
@@ -1814,7 +1864,7 @@ function ExchangeConfigModal({
|
||||
} catch (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 { t } from '../i18n/translations'
|
||||
import { Container } from './Container'
|
||||
|
||||
interface HeaderProps {
|
||||
simple?: boolean // For login/register pages
|
||||
@@ -10,7 +11,7 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Left - Logo and Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -58,7 +59,7 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</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 { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from './ui/input'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login, loginAdmin, verifyOTP } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -26,7 +28,9 @@ export function LoginPage() {
|
||||
setLoading(true)
|
||||
const result = await loginAdmin(adminPassword)
|
||||
if (!result.success) {
|
||||
setError(result.message || t('loginFailed', language))
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,7 +48,9 @@ export function LoginPage() {
|
||||
setStep('otp')
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('loginFailed', language))
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
@@ -58,7 +64,9 @@ export function LoginPage() {
|
||||
const result = await verifyOTP(userID, otpCode)
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('verificationFailed', language))
|
||||
const msg = result.message || t('verificationFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
@@ -66,286 +74,259 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
||||
<HeaderBar
|
||||
onLoginClick={() => {}}
|
||||
isLoggedIn={false}
|
||||
isHomePage={false}
|
||||
currentPage="login"
|
||||
language={language}
|
||||
onLanguageChange={() => {}}
|
||||
onPageChange={(page) => {
|
||||
console.log('LoginPage onPageChange called with:', page)
|
||||
if (page === 'competition') {
|
||||
window.location.href = '/competition'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
className="flex items-center justify-center py-12"
|
||||
style={{ minHeight: 'calc(100vh - 64px)' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Login Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{adminMode ? (
|
||||
<form onSubmit={handleAdminLogin} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<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>
|
||||
登录 NOFX
|
||||
</h1>
|
||||
<p
|
||||
className="text-sm mt-2"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
|
||||
</p>
|
||||
</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"
|
||||
{/* Login Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{adminMode ? (
|
||||
<form onSubmit={handleAdminLogin} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
background: '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) : '登录'}
|
||||
</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>
|
||||
{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) : '登录'}
|
||||
</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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('passwordPlaceholder', 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
|
||||
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
|
||||
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)',
|
||||
}}
|
||||
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)' }}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('verifyOTP', language)}
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/reset-password')}
|
||||
className="text-xs hover:underline"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
{!adminMode && (
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
还没有账户?{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/register')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{loading ? t('loading', language) : t('verifyOTP', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
import { toast } from 'sonner'
|
||||
import { copyWithToast } from '../lib/clipboard'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from './ui/input'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
@@ -11,6 +13,7 @@ import PasswordChecklist from 'react-password-checklist'
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage()
|
||||
const { register, completeRegistration } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
|
||||
'register'
|
||||
)
|
||||
@@ -66,7 +69,9 @@ export function RegisterPage() {
|
||||
setQrCodeURL(result.qrCodeURL || '')
|
||||
setStep('setup-otp')
|
||||
} else {
|
||||
setError(result.message || t('registrationFailed', language))
|
||||
const msg = result.message || t('registrationFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
@@ -84,7 +89,9 @@ export function RegisterPage() {
|
||||
const result = await completeRegistration(userID, otpCode)
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || t('registrationFailed', language))
|
||||
const msg = result.message || t('registrationFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
@@ -92,141 +99,197 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
copyWithToast(text)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
|
||||
<HeaderBar
|
||||
isLoggedIn={false}
|
||||
isHomePage={false}
|
||||
currentPage="register"
|
||||
language={language}
|
||||
onLanguageChange={() => {}}
|
||||
onPageChange={(page) => {
|
||||
console.log('RegisterPage onPageChange called with:', page)
|
||||
if (page === 'competition') {
|
||||
window.location.href = '/competition'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
className="flex items-center justify-center py-12"
|
||||
style={{ minHeight: 'calc(100vh - 64px)' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{step === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
{/* Registration Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{step === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
<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)' }}
|
||||
>
|
||||
{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>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
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)}
|
||||
</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)' }}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码规则清单(通过才允许提交) */}
|
||||
{/* 密码规则清单(通过才允许提交) */}
|
||||
<div
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="mb-1"
|
||||
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
|
||||
className="mb-1"
|
||||
@@ -259,297 +322,245 @@ export function RegisterPage() {
|
||||
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>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
{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)' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify-otp' && (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">🔐</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('enterOTPCode', language)}
|
||||
<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) =>
|
||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('setup-otp')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
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)',
|
||||
}}
|
||||
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('registerButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup-otp' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📱</div>
|
||||
<h3
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('setupTwoFactor', language)}
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('setupTwoFactorDesc', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="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)}
|
||||
: t('completeRegistration', language)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify-otp' && (
|
||||
<form onSubmit={handleOTPVerify} className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-4xl mb-2">🔐</div>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('enterOTPCode', language)}
|
||||
<br />
|
||||
{t('completeRegistrationSubtitle', language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('otpCode', language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={(e) =>
|
||||
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('otpPlaceholder', language)}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('setup-otp')}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{
|
||||
background: 'var(--panel-bg-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{t('back', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Header } from './Header'
|
||||
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
import { Input } from './ui/input'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -38,13 +39,16 @@ export function ResetPasswordPage() {
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true)
|
||||
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
||||
// 3秒后跳转到登录页面
|
||||
setTimeout(() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, 3000)
|
||||
} else {
|
||||
setError(result.message || t('resetPasswordFailed', language))
|
||||
const msg = result.message || t('resetPasswordFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
@@ -217,12 +219,11 @@ export function TraderConfigModal({
|
||||
const currentBalance = data.total_equity || data.balance || 0
|
||||
|
||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||
|
||||
// 显示成功提示
|
||||
console.log('已获取当前余额:', currentBalance)
|
||||
toast.success('已获取当前余额')
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error)
|
||||
setBalanceFetchError('获取余额失败,请检查网络连接')
|
||||
toast.error('获取余额失败,请检查网络连接')
|
||||
} finally {
|
||||
setIsFetchingBalance(false)
|
||||
}
|
||||
@@ -249,7 +250,11 @@ export function TraderConfigModal({
|
||||
initial_balance: formData.initial_balance,
|
||||
scan_interval_minutes: formData.scan_interval_minutes,
|
||||
}
|
||||
await onSave(saveData)
|
||||
await toast.promise(onSave(saveData), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
error: '保存失败',
|
||||
})
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
@@ -268,8 +273,12 @@ export function TraderConfigModal({
|
||||
{/* 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 gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center">
|
||||
<span className="text-lg">{isEditMode ? '✏️' : '➕'}</span>
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#F0B90B] to-[#E1A706] flex items-center justify-center text-black">
|
||||
{isEditMode ? (
|
||||
<Pencil className="w-5 h-5" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#EAECEF]">
|
||||
@@ -284,7 +293,7 @@ export function TraderConfigModal({
|
||||
onClick={onClose}
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { TraderConfigData } from '../types'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
@@ -27,8 +28,10 @@ export function TraderConfigViewModal({
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(fieldName)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
toast.success('已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const DEFAULT_LENGTH = 64
|
||||
|
||||
@@ -99,12 +100,14 @@ export function TwoStageKeyModal({
|
||||
...obfuscationLog,
|
||||
`Stage 1: ${new Date().toISOString()} - Auto copied obfuscation`,
|
||||
])
|
||||
toast.success('已复制混淆字符串到剪贴板')
|
||||
} catch {
|
||||
setClipboardStatus('failed')
|
||||
setObfuscationLog([
|
||||
...obfuscationLog,
|
||||
`Stage 1: ${new Date().toISOString()} - Auto copy failed, manual required`,
|
||||
])
|
||||
toast.error('复制失败,请手动复制混淆字符串')
|
||||
}
|
||||
} else {
|
||||
setClipboardStatus('failed')
|
||||
@@ -112,6 +115,7 @@ export function TwoStageKeyModal({
|
||||
...obfuscationLog,
|
||||
`Stage 1: ${new Date().toISOString()} - Clipboard API not available`,
|
||||
])
|
||||
toast('当前浏览器不支持自动复制,请手动复制')
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { Container } from '../Container'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { FAQSearchBar } from './FAQSearchBar'
|
||||
import { FAQSidebar } from './FAQSidebar'
|
||||
@@ -57,7 +58,7 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
@@ -176,6 +177,6 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
||||
</a>
|
||||
</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);
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.mono {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
@@ -235,6 +298,113 @@ button:disabled {
|
||||
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 {
|
||||
border-color: var(--panel-border-hover);
|
||||
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
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export class HttpClient {
|
||||
// Singleton flag to prevent duplicate 401 handling
|
||||
private static isHandling401 = false
|
||||
@@ -23,52 +25,7 @@ export class HttpClient {
|
||||
* Show login required notification to user
|
||||
*/
|
||||
private showLoginRequiredNotification(): void {
|
||||
// Create notification element
|
||||
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)
|
||||
toast.warning('登录已过期,请先登录', { duration: 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 ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { Toaster } from 'sonner'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<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 />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import HeaderBar from '../components/landing/HeaderBar'
|
||||
import { FAQLayout } from '../components/faq/FAQLayout'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
/**
|
||||
* FAQ 页面
|
||||
*
|
||||
* 这个页面只是组件的集合,负责:
|
||||
* - 组装 HeaderBar 和 FAQLayout
|
||||
* - 提供全局状态(语言、用户、系统配置)
|
||||
* - 处理页面级别的导航
|
||||
* HeaderBar 和 Footer 现在由 MainLayout 提供
|
||||
*
|
||||
* 所有 FAQ 相关的逻辑都在子组件中:
|
||||
* - FAQLayout: 整体布局和搜索逻辑
|
||||
@@ -22,54 +15,7 @@ import { t } from '../i18n/translations'
|
||||
* FAQ 数据配置在 data/faqData.ts
|
||||
*/
|
||||
export function FAQPage() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const { user, logout } = useAuth()
|
||||
useSystemConfig() // Load system config but don't use it
|
||||
const { language } = useLanguage()
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <FAQLayout language={language} />
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import HeaderBar from '../components/landing/HeaderBar'
|
||||
import HeaderBar from '../components/HeaderBar'
|
||||
import HeroSection from '../components/landing/HeroSection'
|
||||
import AboutSection from '../components/landing/AboutSection'
|
||||
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