Merge branch 'dev' into beta

# Conflicts:
#	.github/workflows/docker-build.yml
#	.gitignore
#	api/server.go
#	config/config.go
#	config/database.go
#	decision/engine.go
#	docker-compose.yml
#	go.mod
#	go.sum
#	logger/telegram_sender.go
#	main.go
#	mcp/client.go
#	prompts/adaptive.txt
#	prompts/default.txt
#	prompts/nof1.txt
#	start.sh
#	trader/aster_trader.go
#	trader/auto_trader.go
#	trader/binance_futures.go
#	trader/hyperliquid_trader.go
#	web/package-lock.json
#	web/package.json
#	web/src/App.tsx
#	web/src/components/AILearning.tsx
#	web/src/components/AITradersPage.tsx
#	web/src/components/CompetitionPage.tsx
#	web/src/components/EquityChart.tsx
#	web/src/components/Header.tsx
#	web/src/components/LoginPage.tsx
#	web/src/components/RegisterPage.tsx
#	web/src/components/TraderConfigModal.tsx
#	web/src/components/TraderConfigViewModal.tsx
#	web/src/components/landing/FooterSection.tsx
#	web/src/components/landing/HeaderBar.tsx
#	web/src/contexts/AuthContext.tsx
#	web/src/i18n/translations.ts
#	web/src/lib/api.ts
#	web/src/lib/config.ts
#	web/src/types.ts
This commit is contained in:
Icy
2025-11-12 23:20:25 +08:00
143 changed files with 32902 additions and 3582 deletions

View File

@@ -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">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,329 @@
import { describe, it, expect } from 'vitest'
/**
* PR #678 測試: 修復 CompetitionPage 中 NaN 和缺失數據的顯示問題
*
* 問題:當 total_pnl_pct 為 null/undefined/NaN 時,會顯示 "NaN%" 或 "0.00%"
* 修復:檢查數據有效性,顯示 "—" 表示缺失數據
*/
describe('CompetitionPage - Data Validation Logic (PR #678)', () => {
/**
* 測試數據有效性檢查邏輯
* 這是 PR #678 引入的核心邏輯
*/
describe('hasValidData check', () => {
it('should return true for valid numbers', () => {
const trader1 = { total_pnl_pct: 10.5 }
const trader2 = { total_pnl_pct: -5.2 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(true)
})
it('should return false when trader1 has null value', () => {
const trader1 = { total_pnl_pct: null }
const trader2 = { total_pnl_pct: 10.5 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct!) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(false)
})
it('should return false when trader2 has undefined value', () => {
const trader1 = { total_pnl_pct: 10.5 }
const trader2 = { total_pnl_pct: undefined }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct!)
expect(hasValidData).toBe(false)
})
it('should return false when trader1 has NaN value', () => {
const trader1 = { total_pnl_pct: NaN }
const trader2 = { total_pnl_pct: 10.5 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(false)
})
it('should return false when both traders have invalid data', () => {
const trader1 = { total_pnl_pct: null }
const trader2 = { total_pnl_pct: NaN }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct!) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(false)
})
it('should handle zero as valid data', () => {
const trader1 = { total_pnl_pct: 0 }
const trader2 = { total_pnl_pct: 10.5 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(true)
})
it('should handle negative numbers as valid data', () => {
const trader1 = { total_pnl_pct: -15.5 }
const trader2 = { total_pnl_pct: -8.2 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
expect(hasValidData).toBe(true)
})
})
/**
* 測試 gap 計算邏輯
* gap 應該只在數據有效時計算
*/
describe('gap calculation', () => {
it('should calculate gap correctly for valid data', () => {
const trader1 = { total_pnl_pct: 15.5 }
const trader2 = { total_pnl_pct: 10.2 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
const gap = hasValidData
? trader1.total_pnl_pct - trader2.total_pnl_pct
: NaN
expect(gap).toBeCloseTo(5.3, 1) // Allow floating point precision
expect(isNaN(gap)).toBe(false)
})
it('should return NaN for invalid data', () => {
const trader1 = { total_pnl_pct: null }
const trader2 = { total_pnl_pct: 10.2 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct!) &&
!isNaN(trader2.total_pnl_pct)
const gap = hasValidData
? trader1.total_pnl_pct! - trader2.total_pnl_pct
: NaN
expect(isNaN(gap)).toBe(true)
})
it('should handle negative gap correctly', () => {
const trader1 = { total_pnl_pct: 5.0 }
const trader2 = { total_pnl_pct: 12.0 }
const hasValidData =
trader1.total_pnl_pct != null &&
trader2.total_pnl_pct != null &&
!isNaN(trader1.total_pnl_pct) &&
!isNaN(trader2.total_pnl_pct)
const gap = hasValidData
? trader1.total_pnl_pct - trader2.total_pnl_pct
: NaN
expect(gap).toBe(-7.0)
})
})
/**
* 測試顯示邏輯
* 修復後應顯示「—」而非「NaN%」或「0.00%」
*/
describe('display formatting', () => {
it('should format valid positive percentage correctly', () => {
const total_pnl_pct = 15.567
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('+15.57%')
})
it('should format valid negative percentage correctly', () => {
const total_pnl_pct = -8.234
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('-8.23%')
})
it('should display "—" for null value', () => {
const total_pnl_pct = null
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('—')
})
it('should display "—" for undefined value', () => {
const total_pnl_pct = undefined
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('—')
})
it('should display "—" for NaN value', () => {
const total_pnl_pct = NaN
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('—')
})
it('should format zero correctly', () => {
const total_pnl_pct = 0
const display =
total_pnl_pct != null && !isNaN(total_pnl_pct)
? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%`
: '—'
expect(display).toBe('+0.00%')
})
})
/**
* 測試領先/落後訊息顯示邏輯
* 只有在數據有效時才顯示 "領先" 或 "落後" 訊息
*/
describe('leading/trailing message display', () => {
it('should show leading message when winning with positive gap', () => {
const isWinning = true
const gap = 5.2
const hasValidData = true
const shouldShowLeading = hasValidData && isWinning && gap > 0
expect(shouldShowLeading).toBe(true)
})
it('should not show leading message when data is invalid', () => {
const isWinning = true
const gap = NaN
const hasValidData = false
const shouldShowLeading = hasValidData && isWinning && gap > 0
expect(shouldShowLeading).toBe(false)
})
it('should show trailing message when losing with negative gap', () => {
const isWinning = false
const gap = -3.5
const hasValidData = true
const shouldShowTrailing = hasValidData && !isWinning && gap < 0
expect(shouldShowTrailing).toBe(true)
})
it('should not show trailing message when data is invalid', () => {
const isWinning = false
const gap = NaN
const hasValidData = false
const shouldShowTrailing = hasValidData && !isWinning && gap < 0
expect(shouldShowTrailing).toBe(false)
})
it('should show fallback "—" when data is invalid', () => {
const hasValidData = false
const shouldShowFallback = !hasValidData
expect(shouldShowFallback).toBe(true)
})
})
/**
* 測試邊界情況
*/
describe('edge cases', () => {
it('should handle very small positive numbers', () => {
const total_pnl_pct = 0.001
const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct)
expect(hasValidData).toBe(true)
})
it('should handle very large numbers', () => {
const total_pnl_pct = 9999.99
const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct)
expect(hasValidData).toBe(true)
})
it('should handle Infinity as invalid (produces NaN in calculations)', () => {
const total_pnl_pct = Infinity
// Infinity 本身不是 NaN但在減法運算中可能導致問題
const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct)
expect(hasValidData).toBe(false)
})
it('should handle -Infinity as invalid', () => {
const total_pnl_pct = -Infinity
const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct)
expect(hasValidData).toBe(false)
})
})
})

View File

@@ -392,7 +392,17 @@ export function CompetitionPage() {
{sortedTraders.map((trader, index) => {
const isWinning = index === 0
const opponent = sortedTraders[1 - index]
const gap = trader.total_pnl_pct - opponent.total_pnl_pct
// Check if both values are valid numbers
const hasValidData =
trader.total_pnl_pct != null &&
opponent.total_pnl_pct != null &&
!isNaN(trader.total_pnl_pct) &&
!isNaN(opponent.total_pnl_pct)
const gap = hasValidData
? trader.total_pnl_pct - opponent.total_pnl_pct
: NaN
return (
<div
@@ -429,10 +439,12 @@ export function CompetitionPage() {
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
}}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
{trader.total_pnl_pct != null &&
!isNaN(trader.total_pnl_pct)
? `${trader.total_pnl_pct >= 0 ? '+' : ''}${trader.total_pnl_pct.toFixed(2)}%`
: '—'}
</div>
{isWinning && gap > 0 && (
{hasValidData && isWinning && gap > 0 && (
<div
className="text-xs font-semibold"
style={{ color: '#0ECB81' }}
@@ -440,7 +452,7 @@ export function CompetitionPage() {
{t('leadingBy', language, { gap: gap.toFixed(2) })}
</div>
)}
{!isWinning && gap < 0 && (
{hasValidData && !isWinning && gap < 0 && (
<div
className="text-xs font-semibold"
style={{ color: '#F6465D' }}
@@ -450,6 +462,14 @@ export function CompetitionPage() {
})}
</div>
)}
{!hasValidData && (
<div
className="text-xs font-semibold"
style={{ color: '#848E9C' }}
>
</div>
)}
</div>
</div>
)

View 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>
)
}

View 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>
)
}

View 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

View File

@@ -12,6 +12,7 @@ import {
import useSWR from 'swr'
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { useAuth } from '../contexts/AuthContext'
import { t } from '../i18n/translations'
import {
AlertTriangle,
@@ -36,10 +37,11 @@ interface EquityChartProps {
export function EquityChart({ traderId }: EquityChartProps) {
const { language } = useLanguage()
const { user, token } = useAuth()
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR<EquityPoint[]>(
traderId ? `equity-history-${traderId}` : 'equity-history',
user && token && traderId ? `equity-history-${traderId}` : null,
() => api.getEquityHistory(traderId),
{
refreshInterval: 30000, // 30秒刷新历史数据更新频率较低
@@ -49,7 +51,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
)
const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account',
user && token && traderId ? `account-${traderId}` : null,
() => api.getAccount(traderId),
{
refreshInterval: 15000, // 15秒刷新配合后端缓存
@@ -113,9 +115,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
: validHistory
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推)
const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
|| (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) // 备选:淨值 - 盈亏
|| 1000; // 默认值(与创建交易员时的默认配置一致)
const initialBalance =
account?.initial_balance || // 从交易员配置读取真实初始余额
(validHistory[0]
? validHistory[0].total_equity - validHistory[0].pnl
: undefined) || // 备选:淨值 - 盈亏
1000 // 默认值(与创建交易员时的默认配置一致)
// 转换数据格式
const chartData = displayHistory.map((point) => {

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -1,19 +1,39 @@
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, verifyOTP } = useAuth()
const { login, loginAdmin, verifyOTP } = useAuth()
const navigate = useNavigate()
const [step, setStep] = useState<'login' | 'otp'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [adminPassword, setAdminPassword] = useState('')
const adminMode = false
const handleAdminLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await loginAdmin(adminPassword)
if (!result.success) {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
}
setLoading(false)
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
@@ -28,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)
@@ -42,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会自动处理登录状态
@@ -50,214 +74,251 @@ 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)' }}
>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
NOFX
</h1>
<p
className="text-sm mt-2"
style={{ color: 'var(--text-secondary)' }}
>
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
</p>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('password', language)}
</label>
<input
type="password"
{/* 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-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)',
}}
>
{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={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
className="pr-10"
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<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 */}
{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)',
}}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('verifyOTP', language)}
</button>
</div>
</form>
)}
</div>
{/* Register Link */}
{!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'))
}}
onClick={() => navigate('/register')}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
@@ -265,7 +326,7 @@ export function LoginPage() {
</button>
</p>
</div>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,377 @@
import { describe, it, expect } from 'vitest'
/**
* PR #XXX 测试: 修复密码校验不一致的问题
*
* 问题RegisterPage 中存在两处密码校验逻辑:
* 1. PasswordChecklist 组件提供的可视化校验
* 2. 自定义的 isStrongPassword 函数
* 这导致校验规则可能不一致
*
* 修复:移除重复的 isStrongPassword 函数,统一使用 PasswordChecklist 的校验结果
*
* 本测试专注于验证密码校验逻辑的一致性,确保:
* 1. 移除了重复的 isStrongPassword 函数
* 2. 使用统一的 PasswordChecklist 校验
* 3. 特殊字符规则在正常显示和错误提示中保持一致
*/
describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
/**
* 测试密码校验规则逻辑
* 这些测试验证密码校验的核心逻辑,与 PasswordChecklist 组件的规则一致
*/
describe('password validation rules', () => {
it('should validate minimum 8 characters', () => {
const password = 'Short1!'
const isValid = password.length >= 8
expect(isValid).toBe(false)
const validPassword = 'LongPass1!'
const isValidPassword = validPassword.length >= 8
expect(isValidPassword).toBe(true)
})
it('should require uppercase letter', () => {
const hasUppercase = (pwd: string) => /[A-Z]/.test(pwd)
expect(hasUppercase('lowercase123!')).toBe(false)
expect(hasUppercase('Uppercase123!')).toBe(true)
expect(hasUppercase('ALLCAPS123!')).toBe(true)
})
it('should require lowercase letter', () => {
const hasLowercase = (pwd: string) => /[a-z]/.test(pwd)
expect(hasLowercase('UPPERCASE123!')).toBe(false)
expect(hasLowercase('Lowercase123!')).toBe(true)
expect(hasLowercase('alllower123!')).toBe(true)
})
it('should require number', () => {
const hasNumber = (pwd: string) => /\d/.test(pwd)
expect(hasNumber('NoNumber!')).toBe(false)
expect(hasNumber('HasNumber1!')).toBe(true)
expect(hasNumber('Multiple123!')).toBe(true)
})
it('should require special character from allowed set', () => {
// 根据 RegisterPage.tsx 中的设置,特殊字符正则为 /[@#$%!&*?]/
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
expect(hasSpecialChar('NoSpecial123')).toBe(false)
expect(hasSpecialChar('HasAt123@')).toBe(true)
expect(hasSpecialChar('HasHash123#')).toBe(true)
expect(hasSpecialChar('HasDollar123$')).toBe(true)
expect(hasSpecialChar('HasPercent123%')).toBe(true)
expect(hasSpecialChar('HasExclaim123!')).toBe(true)
expect(hasSpecialChar('HasAmpersand123&')).toBe(true)
expect(hasSpecialChar('HasStar123*')).toBe(true)
expect(hasSpecialChar('HasQuestion123?')).toBe(true)
// 不在允许列表中的特殊字符应该不通过
expect(hasSpecialChar('HasCaret123^')).toBe(false)
expect(hasSpecialChar('HasTilde123~')).toBe(false)
})
it('should validate passwords match', () => {
const password = 'StrongPass123!'
const confirmPassword1 = 'StrongPass123!'
const confirmPassword2 = 'DifferentPass123!'
expect(password === confirmPassword1).toBe(true)
expect(password === confirmPassword2).toBe(false)
})
})
/**
* 测试完整的密码强度校验
* 模拟 PasswordChecklist 的完整校验逻辑
*/
describe('complete password strength validation', () => {
const validatePassword = (
pwd: string,
confirmPwd: string
): {
minLength: boolean
hasUppercase: boolean
hasLowercase: boolean
hasNumber: boolean
hasSpecialChar: boolean
match: boolean
isValid: boolean
} => {
const minLength = pwd.length >= 8
const hasUppercase = /[A-Z]/.test(pwd)
const hasLowercase = /[a-z]/.test(pwd)
const hasNumber = /\d/.test(pwd)
const hasSpecialChar = /[@#$%!&*?]/.test(pwd)
const match = pwd === confirmPwd
return {
minLength,
hasUppercase,
hasLowercase,
hasNumber,
hasSpecialChar,
match,
isValid:
minLength &&
hasUppercase &&
hasLowercase &&
hasNumber &&
hasSpecialChar &&
match,
}
}
it('should reject password with only lowercase', () => {
const result = validatePassword('lowercase123!', 'lowercase123!')
expect(result.hasLowercase).toBe(true)
expect(result.hasUppercase).toBe(false)
expect(result.isValid).toBe(false)
})
it('should reject password with only uppercase', () => {
const result = validatePassword('UPPERCASE123!', 'UPPERCASE123!')
expect(result.hasUppercase).toBe(true)
expect(result.hasLowercase).toBe(false)
expect(result.isValid).toBe(false)
})
it('should reject password without numbers', () => {
const result = validatePassword('NoNumber!', 'NoNumber!')
expect(result.hasNumber).toBe(false)
expect(result.isValid).toBe(false)
})
it('should reject password without special characters', () => {
const result = validatePassword('NoSpecial123', 'NoSpecial123')
expect(result.hasSpecialChar).toBe(false)
expect(result.isValid).toBe(false)
})
it('should reject password less than 8 characters', () => {
const result = validatePassword('Short1!', 'Short1!')
expect(result.minLength).toBe(false)
expect(result.isValid).toBe(false)
})
it('should reject when passwords do not match', () => {
const result = validatePassword('StrongPass123!', 'DifferentPass123!')
expect(result.match).toBe(false)
expect(result.isValid).toBe(false)
})
it('should accept strong password meeting all requirements', () => {
const result = validatePassword('StrongPass123!', 'StrongPass123!')
expect(result.minLength).toBe(true)
expect(result.hasUppercase).toBe(true)
expect(result.hasLowercase).toBe(true)
expect(result.hasNumber).toBe(true)
expect(result.hasSpecialChar).toBe(true)
expect(result.match).toBe(true)
expect(result.isValid).toBe(true)
})
it('should accept password with exactly 8 characters', () => {
const result = validatePassword('Pass123!', 'Pass123!')
expect(result.isValid).toBe(true)
})
it('should accept password with multiple special characters', () => {
const result = validatePassword('Pass123!@#', 'Pass123!@#')
expect(result.isValid).toBe(true)
})
it('should accept very long password', () => {
const longPassword = 'VeryLongStrongPassword123!@#$%'
const result = validatePassword(longPassword, longPassword)
expect(result.isValid).toBe(true)
})
})
/**
* 测试特殊字符一致性
* 确保在 RegisterPage 的正常显示(第 229-251 行)和错误提示(第 300-323 行)中
* 使用相同的特殊字符正则 /[@#$%!&*?]/
*/
describe('special character consistency', () => {
it('should use consistent special character regex across all validations', () => {
// RegisterPage 中两处 PasswordChecklist 都应该使用相同的 specialCharsRegex
const specialCharsRegex = /[@#$%!&*?]/
// 测试允许的特殊字符
const validSpecialChars = ['@', '#', '$', '%', '!', '&', '*', '?']
validSpecialChars.forEach((char) => {
expect(specialCharsRegex.test(char)).toBe(true)
})
// 测试不允许的特殊字符
const invalidSpecialChars = ['^', '~', '`', '(', ')', '-', '_', '=', '+']
invalidSpecialChars.forEach((char) => {
expect(specialCharsRegex.test(char)).toBe(false)
})
})
it('should validate all allowed special characters in passwords', () => {
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
const validPasswords = [
'Password123@',
'Password123#',
'Password123$',
'Password123%',
'Password123!',
'Password123&',
'Password123*',
'Password123?',
]
validPasswords.forEach((pwd) => {
expect(hasSpecialChar(pwd)).toBe(true)
})
})
it('should reject passwords with non-allowed special characters', () => {
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
const invalidPasswords = [
'Password123^',
'Password123~',
'Password123`',
'Password123(',
'Password123)',
'Password123-',
'Password123_',
'Password123=',
'Password123+',
]
invalidPasswords.forEach((pwd) => {
expect(hasSpecialChar(pwd)).toBe(false)
})
})
})
/**
* 测试边界情况
*/
describe('edge cases', () => {
const validatePassword = (pwd: string, confirmPwd: string): boolean => {
const minLength = pwd.length >= 8
const hasUppercase = /[A-Z]/.test(pwd)
const hasLowercase = /[a-z]/.test(pwd)
const hasNumber = /\d/.test(pwd)
const hasSpecialChar = /[@#$%!&*?]/.test(pwd)
const match = pwd === confirmPwd
return (
minLength &&
hasUppercase &&
hasLowercase &&
hasNumber &&
hasSpecialChar &&
match
)
}
it('should handle exactly 8 character password', () => {
expect(validatePassword('Pass123!', 'Pass123!')).toBe(true)
})
it('should handle very long password', () => {
const longPassword = 'VeryLongStrongPassword123!@#$%^&*()_+'
expect(validatePassword(longPassword, longPassword)).toBe(true)
})
it('should handle password with all allowed special characters', () => {
const password = 'Pass123@#$%!&*?'
expect(validatePassword(password, password)).toBe(true)
})
it('should handle password with consecutive numbers', () => {
const password = 'Password123456789!'
expect(validatePassword(password, password)).toBe(true)
})
it('should handle password with consecutive special characters', () => {
const password = 'Pass123!@#$%'
expect(validatePassword(password, password)).toBe(true)
})
it('should be case sensitive for matching', () => {
expect(validatePassword('Password123!', 'password123!')).toBe(false)
expect(validatePassword('password123!', 'Password123!')).toBe(false)
})
it('should not accept whitespace as special character', () => {
const hasSpecialChar = /[@#$%!&*?]/.test('Password123 ')
expect(hasSpecialChar).toBe(false)
})
})
/**
* 测试重构后的一致性
* 确保移除 isStrongPassword 函数后,所有校验都通过 PasswordChecklist
*/
describe('refactoring consistency verification', () => {
it('should have removed duplicate isStrongPassword function', () => {
// 这个测试验证重构的意图:
// 在重构之前,存在一个 isStrongPassword 函数
// 重构后应该移除该函数,只使用 PasswordChecklist 的校验
// 我们通过模拟 PasswordChecklist 的逻辑来验证一致性
const passwordChecklistValidation = (pwd: string, confirm: string) => {
return {
minLength: pwd.length >= 8,
capital: /[A-Z]/.test(pwd),
lowercase: /[a-z]/.test(pwd),
number: /\d/.test(pwd),
specialChar: /[@#$%!&*?]/.test(pwd),
match: pwd === confirm,
}
}
// 测试几个密码
const testCases = [
{ pwd: 'Weak', confirm: 'Weak', shouldPass: false },
{ pwd: 'StrongPass123!', confirm: 'StrongPass123!', shouldPass: true },
{ pwd: 'NoNumber!', confirm: 'NoNumber!', shouldPass: false },
{ pwd: 'Pass123!', confirm: 'Pass123!', shouldPass: true },
]
testCases.forEach((testCase) => {
const result = passwordChecklistValidation(
testCase.pwd,
testCase.confirm
)
const isValid = Object.values(result).every((v) => v === true)
expect(isValid).toBe(testCase.shouldPass)
})
})
it('should use consistent validation logic across the component', () => {
// 验证校验逻辑的一致性
const validation1 = {
minLength: 8,
requireCapital: true,
requireLowercase: true,
requireNumber: true,
requireSpecialChar: true,
specialCharsRegex: /[@#$%!&*?]/,
}
// 在 RegisterPage 的正常显示和错误提示中应该使用相同的配置
const validation2 = {
minLength: 8,
requireCapital: true,
requireLowercase: true,
requireNumber: true,
requireSpecialChar: true,
specialCharsRegex: /[@#$%!&*?]/,
}
expect(validation1).toEqual(validation2)
})
})
})

View File

@@ -1,13 +1,19 @@
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'
export function RegisterPage() {
const { language } = useLanguage()
const { register, completeRegistration } = useAuth()
const navigate = useNavigate()
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
'register'
)
@@ -22,6 +28,9 @@ export function RegisterPage() {
const [qrCodeURL, setQrCodeURL] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式
@@ -38,13 +47,9 @@ export function RegisterPage() {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError(t('passwordMismatch', language))
return
}
if (password.length < 6) {
setError(t('passwordTooShort', language))
// 使用 PasswordChecklist 的校验结果
if (!passwordValid) {
setError(t('passwordNotMeetRequirements', language))
return
}
@@ -63,7 +68,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)
@@ -81,7 +88,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会自动处理登录状态
@@ -89,413 +98,468 @@ 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>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
{/* 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>
<input
type="password"
<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="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
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('confirmPassword', language)}
</label>
<input
type="password"
{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)' }}
>
{t('confirmPassword', language)}
</label>
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
className="pr-10"
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</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)',
}}
<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)' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{loading
? t('loading', language)
: t('registerButton', language)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3
className="text-lg font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="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>
{showConfirmPassword ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
<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>
</button>
</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
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
<div
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: 'var(--brand-light-gray)' }}
style={{ color: '#EAECEF' }}
>
{t('otpCode', language)}
*
</label>
<input
type="text"
value={otpCode}
value={betaCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
setBetaCode(
e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase()
)
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
className="w-full px-3 py-2 rounded font-mono"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder={t('otpPlaceholder', language)}
placeholder="请输入6位内测码"
maxLength={6}
required
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"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
specialCharsRegex={/[@#$%!&*?]/}
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>
)}
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={
loading || (betaMode && !betaCode.trim()) || !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>
)}
<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)' }}
{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' }}
>
</button>
</p>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="space-y-3">
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep1Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep1Desc', language)}
</p>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('authStep2Desc', language)}
</p>
{qrCodeURL && (
<div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('qrCodeHint', language)}
</p>
<div className="bg-white p-2 rounded text-center">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code"
className="mx-auto"
/>
</div>
</div>
)}
<div className="mt-2">
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('otpSecret', language)}
</p>
<div className="flex items-center gap-2">
<code
className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--brand-light-gray)',
}}
>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('copy', language)}
</button>
</div>
</div>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep3Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep3Desc', language)}
</p>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('setupCompleteContinue', language)}
</button>
</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={() => navigate('/login')}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,293 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
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()
const { resetPassword } = useAuth()
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [otpCode, setOtpCode] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess(false)
// 验证两次密码是否一致
if (newPassword !== confirmPassword) {
setError(t('passwordMismatch', language))
return
}
setLoading(true)
const result = await resetPassword(email, newPassword, otpCode)
if (result.success) {
setSuccess(true)
toast.success(t('resetPasswordSuccess', language) || '重置成功')
// 3秒后跳转到登录页面
setTimeout(() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}, 3000)
} else {
const msg = result.message || t('resetPasswordFailed', language)
setError(msg)
toast.error(msg)
}
setLoading(false)
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
<Header simple />
<div
className="flex items-center justify-center"
style={{ minHeight: 'calc(100vh - 80px)' }}
>
<div className="w-full max-w-md">
{/* Back to Login */}
<button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
style={{ color: '#848E9C' }}
>
<ArrowLeft className="w-4 h-4" />
{t('backToLogin', language)}
</button>
{/* Logo */}
<div className="text-center mb-8">
<div
className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full"
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
>
<KeyRound className="w-8 h-8" style={{ color: '#F0B90B' }} />
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('resetPasswordTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
使 Google Authenticator
</p>
</div>
{/* Reset Password Form */}
<div
className="rounded-lg p-6"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
{success ? (
<div className="text-center py-8">
<div className="text-5xl mb-4"></div>
<p
className="text-lg font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('resetPasswordSuccess', language)}
</p>
<p className="text-sm" style={{ color: '#848E9C' }}>
3...
</p>
</div>
) : (
<form onSubmit={handleResetPassword} className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{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: '#EAECEF' }}
>
{t('newPassword', language)}
</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="pr-10"
placeholder={t('newPasswordPlaceholder', language)}
required
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{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"
onMouseDown={(e) => e.preventDefault()}
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
{/* 密码强度检查(必须通过才允许提交) */}
<div
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
<div
className="mb-1"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
value={newPassword}
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>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('otpCode', language)}
</label>
<div className="text-center mb-3">
<div className="text-3xl">📱</div>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
Google Authenticator 6
</p>
</div>
<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: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || otpCode.length !== 6 || !passwordValid}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('resetPasswordButton', language)}
</button>
</form>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 {
@@ -102,7 +104,7 @@ export function TraderConfigModal({
}
// 确保旧数据也有默认的 system_prompt_template
if (traderData && traderData.system_prompt_template === undefined) {
setFormData(prev => ({
setFormData((prev) => ({
...prev,
system_prompt_template: 'default',
}))
@@ -153,12 +155,6 @@ export function TraderConfigModal({
fetchPromptTemplates()
}, [])
// 当选择的币种改变时,更新输入框
useEffect(() => {
const symbolsString = selectedCoins.join(',')
setFormData((prev) => ({ ...prev, trading_symbols: symbolsString }))
}, [selectedCoins])
if (!isOpen) return null
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
@@ -176,52 +172,62 @@ export function TraderConfigModal({
const handleCoinToggle = (coin: string) => {
setSelectedCoins((prev) => {
if (prev.includes(coin)) {
return prev.filter((c) => c !== coin)
} else {
return [...prev, coin]
}
const newCoins = prev.includes(coin)
? prev.filter((c) => c !== coin)
: [...prev, coin]
// 同时更新 formData.trading_symbols
const symbolsString = newCoins.join(',')
setFormData((current) => ({ ...current, trading_symbols: symbolsString }))
return newCoins
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
setBalanceFetchError('只有在编辑模式下才能获取当前余额');
return;
setBalanceFetchError('只有在编辑模式下才能获取当前余额')
return
}
setIsFetchingBalance(true);
setBalanceFetchError('');
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/account?trader_id=${traderData.trader_id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('获取账户余额失败');
const token = localStorage.getItem('auth_token')
if (!token) {
throw new Error('未登录,请先登录')
}
const data = await response.json();
const response = await fetch(
`/api/account?trader_id=${traderData.trader_id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
if (!response.ok) {
throw new Error('获取账户余额失败')
}
const data = await response.json()
// total_equity = 当前账户净值(包含未实现盈亏)
// 这应该作为新的初始余额
const currentBalance = data.total_equity || data.balance || 0;
const currentBalance = data.total_equity || data.balance || 0
setFormData(prev => ({ ...prev, initial_balance: currentBalance }));
// 显示成功提示
console.log('已获取当前余额:', currentBalance);
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success('已获取当前余额')
} catch (error) {
console.error('获取余额失败:', error);
setBalanceFetchError('获取余额失败,请检查网络连接');
console.error('获取余额失败:', error)
setBalanceFetchError('获取余额失败,请检查网络连接')
toast.error('获取余额失败,请检查网络连接')
} finally {
setIsFetchingBalance(false);
setIsFetchingBalance(false)
}
};
}
const handleSave = async () => {
if (!onSave) return
@@ -244,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)
@@ -254,16 +264,21 @@ export function TraderConfigModal({
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full my-8"
style={{ maxHeight: 'calc(100vh - 4rem)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<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]">
@@ -278,12 +293,15 @@ 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>
{/* Content */}
<div className="p-6 space-y-8">
<div
className="p-6 space-y-8 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 16rem)' }}
>
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
@@ -390,7 +408,9 @@ export function TraderConfigModal({
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
{!isEditMode && <span className="text-[#F0B90B] ml-1">*</span>}
{!isEditMode && (
<span className="text-[#F0B90B] ml-1">*</span>
)}
</label>
{isEditMode && (
<button
@@ -412,13 +432,33 @@ export function TraderConfigModal({
Number(e.target.value)
)
}
onBlur={(e) => {
// Force minimum value on blur
const value = Number(e.target.value)
if (value < 100) {
handleInputChange('initial_balance', 100)
}
}}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="0.01"
/>
{!isEditMode && (
<p className="text-xs text-[#F0B90B] mt-1 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
P&L统计将会错误
</p>
)}
@@ -428,7 +468,9 @@ export function TraderConfigModal({
</p>
)}
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">{balanceFetchError}</p>
<p className="text-xs text-red-500 mt-1">
{balanceFetchError}
</p>
)}
</div>
</div>
@@ -597,7 +639,7 @@ export function TraderConfigModal({
{/* 系统提示词模板选择 */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('systemPromptTemplate', language)}
</label>
<select
value={formData.system_prompt_template}
@@ -606,17 +648,75 @@ export function TraderConfigModal({
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{promptTemplates.map((template) => (
<option key={template.name} value={template.name}>
{template.name === 'default'
? 'Default (默认稳健)'
: template.name === 'aggressive'
? 'Aggressive (激进)'
: template.name.charAt(0).toUpperCase() +
template.name.slice(1)}
</option>
))}
{promptTemplates.map((template) => {
// Template name mapping with i18n
const getTemplateName = (name: string) => {
const keyMap: Record<string, string> = {
default: 'promptTemplateDefault',
adaptive: 'promptTemplateAdaptive',
adaptive_relaxed: 'promptTemplateAdaptiveRelaxed',
Hansen: 'promptTemplateHansen',
nof1: 'promptTemplateNof1',
taro_long_prompts: 'promptTemplateTaroLong',
}
const key = keyMap[name]
return key
? t(key, language)
: name.charAt(0).toUpperCase() + name.slice(1)
}
return (
<option key={template.name} value={template.name}>
{getTemplateName(template.name)}
</option>
)
})}
</select>
{/* 動態描述區域 */}
<div
className="mt-2 p-3 rounded"
style={{
background: 'rgba(240, 185, 11, 0.05)',
border: '1px solid rgba(240, 185, 11, 0.15)',
}}
>
<div
className="text-xs font-semibold mb-1"
style={{ color: '#F0B90B' }}
>
{(() => {
const titleKeyMap: Record<string, string> = {
default: 'promptDescDefault',
adaptive: 'promptDescAdaptive',
adaptive_relaxed: 'promptDescAdaptiveRelaxed',
Hansen: 'promptDescHansen',
nof1: 'promptDescNof1',
taro_long_prompts: 'promptDescTaroLong',
}
const key = titleKeyMap[formData.system_prompt_template]
return key
? t(key, language)
: t('promptDescDefault', language)
})()}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{(() => {
const contentKeyMap: Record<string, string> = {
default: 'promptDescDefaultContent',
adaptive: 'promptDescAdaptiveContent',
adaptive_relaxed: 'promptDescAdaptiveRelaxedContent',
Hansen: 'promptDescHansenContent',
nof1: 'promptDescNof1Content',
taro_long_prompts: 'promptDescTaroLongContent',
}
const key = contentKeyMap[formData.system_prompt_template]
return key
? t(key, language)
: t('promptDescDefaultContent', language)
})()}
</div>
</div>
<p className="text-xs text-[#848E9C] mt-1">
</p>
@@ -674,7 +774,7 @@ export function TraderConfigModal({
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex justify-end gap-3 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35] sticky bottom-0 z-10 rounded-b-xl">
<button
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"

View File

@@ -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('复制失败,请手动复制')
}
}

View File

@@ -0,0 +1,347 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { t, type Language } from '../i18n/translations'
import { toast } from 'sonner'
import { WebCryptoEnvironmentCheck } from './WebCryptoEnvironmentCheck'
const DEFAULT_LENGTH = 64
function generateObfuscation(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
''
)
}
function validatePrivateKeyFormat(
value: string,
expectedLength: number
): boolean {
const normalized = value.startsWith('0x') ? value.slice(2) : value
if (normalized.length !== expectedLength) {
return false
}
return /^[0-9a-fA-F]+$/.test(normalized)
}
export interface TwoStageKeyModalResult {
value: string
obfuscationLog: string[]
}
interface TwoStageKeyModalProps {
isOpen: boolean
language: Language
onCancel: () => void
onComplete: (result: TwoStageKeyModalResult) => void
expectedLength?: number
contextLabel?: string
}
export function TwoStageKeyModal({
isOpen,
language,
onCancel,
onComplete,
expectedLength = DEFAULT_LENGTH,
contextLabel,
}: TwoStageKeyModalProps) {
const [stage, setStage] = useState<1 | 2>(1)
const [part1, setPart1] = useState('')
const [part2, setPart2] = useState('')
const [error, setError] = useState<string | null>(null)
const [clipboardStatus, setClipboardStatus] = useState<
'idle' | 'copied' | 'failed'
>('idle')
const [obfuscationLog, setObfuscationLog] = useState<string[]>([])
const [processing, setProcessing] = useState(false)
const [manualObfuscationValue, setManualObfuscationValue] = useState<
string | null
>(null)
const stage1Ref = useRef<HTMLInputElement>(null)
const stage2Ref = useRef<HTMLInputElement>(null)
// UX improvement: Use 58 + 6 split (most of the key + last 6 chars)
// Advantage: Second stage only requires entering 6 characters, much easier to count
const expectedPart1Length = expectedLength - 6 // 64 - 6 = 58
const expectedPart2Length = 6 // Last 6 characters
useEffect(() => {
if (isOpen && stage === 1 && stage1Ref.current) {
stage1Ref.current.focus()
} else if (isOpen && stage === 2 && stage2Ref.current) {
stage2Ref.current.focus()
}
}, [isOpen, stage])
const handleStage1Next = async () => {
// ✅ Normalize input (remove possible 0x prefix) before validating length
const normalized1 = part1.startsWith('0x') ? part1.slice(2) : part1
if (normalized1.length < expectedPart1Length) {
setError(
t('errors.privatekeyIncomplete', language, {
expected: expectedPart1Length,
})
)
return
}
setError(null)
setProcessing(true)
try {
// 生成混淆字符串
const obfuscation = generateObfuscation()
setManualObfuscationValue(obfuscation)
// 尝试复制到剪贴板
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(obfuscation)
setClipboardStatus('copied')
setObfuscationLog([
...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')
setObfuscationLog([
...obfuscationLog,
`Stage 1: ${new Date().toISOString()} - Clipboard API not available`,
])
toast('当前浏览器不支持自动复制,请手动复制')
}
setTimeout(() => {
setStage(2)
setProcessing(false)
}, 2000)
} catch (err) {
setError(t('errors.privatekeyObfuscationFailed', language))
setProcessing(false)
}
}
const handleStage2Complete = () => {
// ✅ Normalize input (remove possible 0x prefix) before validating length
const normalized2 = part2.startsWith('0x') ? part2.slice(2) : part2
if (normalized2.length < expectedPart2Length) {
setError(
t('errors.privatekeyIncomplete', language, {
expected: expectedPart2Length,
})
)
return
}
// ✅ Concatenate after removing 0x prefix from both parts
const normalized1 = part1.startsWith('0x') ? part1.slice(2) : part1
const fullKey = normalized1 + normalized2
if (!validatePrivateKeyFormat(fullKey, expectedLength)) {
setError(t('errors.privatekeyInvalidFormat', language))
return
}
const finalLog = [
...obfuscationLog,
`Stage 2: ${new Date().toISOString()} - Completed`,
]
onComplete({
value: fullKey,
obfuscationLog: finalLog,
})
}
const handleReset = () => {
setStage(1)
setPart1('')
setPart2('')
setError(null)
setClipboardStatus('idle')
setObfuscationLog([])
setProcessing(false)
setManualObfuscationValue(null)
}
const modalContent = useMemo(() => {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-gray-900 p-8 rounded-xl max-w-lg w-full mx-4 border border-gray-700">
<div className="text-center mb-6">
<h2 className="text-xl font-bold text-white mb-2">
🔐 {t('twoStageKey.title', language)}
{contextLabel && (
<span className="text-gray-300 text-base font-normal ml-2">
({contextLabel})
</span>
)}
</h2>
<p className="text-gray-300 text-sm">
{stage === 1
? t('twoStageKey.stage1Description', language, {
length: expectedPart1Length,
})
: t('twoStageKey.stage2Description', language, {
length: expectedPart2Length,
})}
</p>
</div>
<div className="mb-6">
<WebCryptoEnvironmentCheck language={language} variant="compact" />
</div>
{/* Stage 1 */}
{stage === 1 && (
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">
{t('twoStageKey.stage1InputLabel', language)} (
{expectedPart1Length} {t('twoStageKey.characters', language)})
</label>
<input
ref={stage1Ref}
type="password"
value={part1}
onChange={(e) => setPart1(e.target.value)}
placeholder="0x1234..."
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white font-mono text-sm focus:border-blue-500 focus:outline-none"
maxLength={expectedPart1Length + 2} // +2 for optional 0x prefix
disabled={processing}
/>
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-3">
<button
onClick={handleStage1Next}
disabled={
(part1.startsWith('0x') ? part1.slice(2) : part1).length <
expectedPart1Length || processing
}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-medium py-3 px-4 rounded-lg transition-colors"
>
{processing
? t('twoStageKey.processing', language)
: t('twoStageKey.nextButton', language)}
</button>
<button
onClick={onCancel}
disabled={processing}
className="px-6 py-3 text-gray-300 hover:text-white border border-gray-600 rounded-lg transition-colors"
>
{t('twoStageKey.cancelButton', language)}
</button>
</div>
</div>
)}
{/* Transition Message */}
{stage === 2 && clipboardStatus !== 'idle' && (
<div className="mb-4 p-4 rounded-lg bg-blue-900/50 border border-blue-600">
{clipboardStatus === 'copied' && (
<div className="text-blue-300">
<div className="font-medium">
{t('twoStageKey.obfuscationCopied', language)}
</div>
<div className="text-sm mt-1">
{t('twoStageKey.obfuscationInstruction', language)}
</div>
</div>
)}
{clipboardStatus === 'failed' && manualObfuscationValue && (
<div className="text-yellow-300">
<div className="font-medium">
{t('twoStageKey.obfuscationManual', language)}
</div>
<div className="text-xs mt-2 p-2 bg-gray-800 rounded font-mono break-all border">
{manualObfuscationValue}
</div>
<div className="text-sm mt-1">
{t('twoStageKey.obfuscationInstruction', language)}
</div>
</div>
)}
</div>
)}
{/* Stage 2 */}
{stage === 2 && (
<div className="space-y-4">
<div>
<label className="block text-gray-300 text-sm mb-2">
{t('twoStageKey.stage2InputLabel', language)} (
{expectedPart2Length} {t('twoStageKey.characters', language)})
</label>
<input
ref={stage2Ref}
type="password"
value={part2}
onChange={(e) => setPart2(e.target.value)}
placeholder="...5678"
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-3 text-white font-mono text-sm focus:border-blue-500 focus:outline-none"
maxLength={expectedPart2Length + 2}
/>
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-3">
<button
onClick={handleStage2Complete}
disabled={
(part2.startsWith('0x') ? part2.slice(2) : part2).length <
expectedPart2Length
}
className="flex-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white font-medium py-3 px-4 rounded-lg transition-colors"
>
🔒 {t('twoStageKey.encryptButton', language)}
</button>
<button
onClick={handleReset}
className="px-6 py-3 text-gray-300 hover:text-white border border-gray-600 rounded-lg transition-colors"
>
{t('twoStageKey.backButton', language)}
</button>
</div>
</div>
)}
</div>
</div>
)
}, [
isOpen,
stage,
part1,
part2,
error,
processing,
clipboardStatus,
manualObfuscationValue,
language,
expectedPart1Length,
expectedPart2Length,
contextLabel,
obfuscationLog,
onCancel,
onComplete,
])
if (!isOpen) return null
return createPortal(modalContent, document.body)
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react'
import { diagnoseWebCryptoEnvironment } from '../lib/crypto'
import { t, type Language } from '../i18n/translations'
export type WebCryptoCheckStatus =
| 'idle'
| 'checking'
| 'secure'
| 'insecure'
| 'unsupported'
interface WebCryptoEnvironmentCheckProps {
language: Language
variant?: 'card' | 'compact'
onStatusChange?: (status: WebCryptoCheckStatus) => void
}
export function WebCryptoEnvironmentCheck({
language,
variant = 'card',
onStatusChange,
}: WebCryptoEnvironmentCheckProps) {
const [status, setStatus] = useState<WebCryptoCheckStatus>('idle')
const [summary, setSummary] = useState<string | null>(null)
useEffect(() => {
onStatusChange?.(status)
}, [onStatusChange, status])
const runCheck = useCallback(() => {
setStatus('checking')
setSummary(null)
setTimeout(() => {
const result = diagnoseWebCryptoEnvironment()
setSummary(
t('environmentCheck.summary', language, {
origin: result.origin || 'N/A',
protocol: result.protocol || 'unknown',
})
)
if (!result.isBrowser || !result.hasSubtleCrypto) {
setStatus('unsupported')
return
}
if (!result.isSecureContext) {
setStatus('insecure')
return
}
setStatus('secure')
}, 0)
}, [language, t])
useEffect(() => {
runCheck()
}, [runCheck])
const isCompact = variant === 'compact'
const containerClass = isCompact
? 'p-3 rounded border border-gray-700 bg-gray-900 space-y-3'
: 'p-4 rounded border border-[#2B3139] bg-[#0B0E11] space-y-4'
const descriptionColor = isCompact ? '#CBD5F5' : '#A1AEC8'
const showInfo = status !== 'idle'
const statusRendererMap: Record<WebCryptoCheckStatus, () => ReactNode> = {
secure: () => (
<div className="flex items-start gap-2 text-green-400 text-xs">
<ShieldCheck className="w-4 h-4 flex-shrink-0" />
<div>
<div className="font-semibold">
{t('environmentCheck.secureTitle', language)}
</div>
<div>{t('environmentCheck.secureDesc', language)}</div>
</div>
</div>
),
insecure: () => (
<div className="text-xs" style={{ color: '#F59E0B' }}>
<div className="flex items-start gap-2 mb-1">
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
<div className="font-semibold">
{t('environmentCheck.insecureTitle', language)}
</div>
</div>
<div>{t('environmentCheck.insecureDesc', language)}</div>
<div className="mt-2 font-semibold">
{t('environmentCheck.tipsTitle', language)}
</div>
<ul className="list-disc pl-5 space-y-1 mt-1">
<li>{t('environmentCheck.tipHTTPS', language)}</li>
<li>{t('environmentCheck.tipLocalhost', language)}</li>
<li>{t('environmentCheck.tipIframe', language)}</li>
</ul>
</div>
),
unsupported: () => (
<div className="text-xs" style={{ color: '#F87171' }}>
<div className="flex items-start gap-2 mb-1">
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
<div className="font-semibold">
{t('environmentCheck.unsupportedTitle', language)}
</div>
</div>
<div>{t('environmentCheck.unsupportedDesc', language)}</div>
</div>
),
checking: () => (
<div
className="flex items-center gap-2 text-xs"
style={{ color: '#EAECEF' }}
>
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('environmentCheck.checking', language)}</span>
</div>
),
idle: () => null,
}
const renderStatus = () => statusRendererMap[status]()
return (
<div className={containerClass}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{showInfo && (
<div className="text-xs" style={{ color: descriptionColor }}>
{summary ?? t('environmentCheck.description', language)}
</div>
)}
</div>
{showInfo && <div className="min-h-[1.5rem]">{renderStatus()}</div>}
</div>
)
}

View File

@@ -0,0 +1,459 @@
import { useEffect, useRef } from 'react'
import { t, type Language } from '../../i18n/translations'
import type { FAQCategory } from '../../data/faqData'
// RoadmapWidget 移除动态嵌入,按需仅展示外部链接
interface FAQContentProps {
categories: FAQCategory[]
language: Language
onActiveItemChange: (itemId: string) => void
}
export function FAQContent({
categories,
language,
onActiveItemChange,
}: FAQContentProps) {
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const itemId = entry.target.getAttribute('data-item-id')
if (itemId) {
onActiveItemChange(itemId)
}
}
})
},
{
rootMargin: '-100px 0px -80% 0px',
threshold: 0,
}
)
sectionRefs.current.forEach((ref) => {
if (ref) observer.observe(ref)
})
return () => {
sectionRefs.current.forEach((ref) => {
if (ref) observer.unobserve(ref)
})
}
}, [onActiveItemChange])
const setRef = (itemId: string, element: HTMLElement | null) => {
if (element) {
sectionRefs.current.set(itemId, element)
} else {
sectionRefs.current.delete(itemId)
}
}
return (
<div className="space-y-12">
{categories.map((category) => (
<div key={category.id}>
{/* Category Header */}
<div
className="flex items-center gap-3 mb-6 pb-3"
style={{ borderBottom: '2px solid #2B3139' }}
>
<category.icon className="w-7 h-7" style={{ color: '#F0B90B' }} />
<h2 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t(category.titleKey, language)}
</h2>
</div>
{/* FAQ Items */}
<div className="space-y-8">
{category.items.map((item) => (
<section
key={item.id}
id={item.id}
data-item-id={item.id}
ref={(el) => setRef(item.id, el)}
className="scroll-mt-24"
>
{/* Question */}
<h3
className="text-xl font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t(item.questionKey, language)}
</h3>
{/* Answer */}
<div
className="prose prose-invert max-w-none"
style={{
color: '#B7BDC6',
lineHeight: '1.7',
}}
>
{item.id === 'github-projects-tasks' ? (
<div className="space-y-3">
<div className="text-base">
{language === 'zh' ? '链接:' : 'Links:'}{' '}
<a
href="https://github.com/orgs/NoFxAiOS/projects/3"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
{language === 'zh' ? '路线图' : 'Roadmap'}
</a>
{' | '}
<a
href="https://github.com/orgs/NoFxAiOS/projects/5"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
{language === 'zh' ? '任务看板' : 'Task Dashboard'}
</a>
</div>
<ol className="list-decimal pl-5 space-y-1 text-base">
{language === 'zh' ? (
<>
<li>
good first issue / help
wanted / frontend / backend
</li>
<li>
Acceptance
Criteria
</li>
<li>assign me</li>
<li>Fork GitHub </li>
<li>
fork <code>dev</code>{' '}
<code className="ml-2">
git remote add upstream
https://github.com/NoFxAiOS/nofx.git
</code>
<br />
<code>git fetch upstream</code>
<br />
<code>git checkout dev</code>
<br />
<code>git rebase upstream/dev</code>
<br />
<code>git push origin dev</code>
</li>
<li>
fork <code>dev</code>
<code className="ml-2">
git checkout -b feat/your-topic
</code>
</li>
<li>
fork
<code className="ml-2">
git push origin feat/your-topic
</code>
</li>
<li>
PRbase <code>NoFxAiOS/nofx:dev</code>{' '}
compare {' '}
<code>/nofx:feat/your-topic</code>
</li>
<li>
PR Issue
<code className="ml-1">Closes #123</code>
PR {' '}
<code>upstream/dev</code>{' '}
rebase
</li>
</>
) : (
<>
<li>
Open the links above and filter by labels (good
first issue / help wanted / frontend / backend).
</li>
<li>
Open the task and read the Description &
Acceptance Criteria.
</li>
<li>
Comment "assign me" or self-assign (if permitted).
</li>
<li>Fork the repository to your GitHub account.</li>
<li>
Sync your fork's <code>dev</code> with upstream:
<code className="ml-2">
git remote add upstream
https://github.com/NoFxAiOS/nofx.git
</code>
<br />
<code>git fetch upstream</code>
<br />
<code>git checkout dev</code>
<br />
<code>git rebase upstream/dev</code>
<br />
<code>git push origin dev</code>
</li>
<li>
Create a feature branch from your fork's{' '}
<code>dev</code>:
<code className="ml-2">
git checkout -b feat/your-topic
</code>
</li>
<li>
Push to your fork:
<code className="ml-2">
git push origin feat/your-topic
</code>
</li>
<li>
Open a PR: base <code>NoFxAiOS/nofx:dev</code>
compare{' '}
<code>your-username/nofx:feat/your-topic</code>.
</li>
<li>
In PR, reference the Issue (e.g.,{' '}
<code className="ml-1">Closes #123</code>) and
choose the proper PR template; rebase onto{' '}
<code>upstream/dev</code> as needed.
</li>
</>
)}
</ol>
<div
className="rounded p-3 mt-3"
style={{
background: 'rgba(240, 185, 11, 0.08)',
border: '1px solid rgba(240, 185, 11, 0.25)',
}}
>
{language === 'zh' ? (
<div className="text-sm">
<strong style={{ color: '#F0B90B' }}></strong>{' '}
Bounty/
Review/
<a
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
bounty
</a>
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
Bounty Claim
</a>
</div>
) : (
<div className="text-sm">
<strong style={{ color: '#F0B90B' }}>Note:</strong>{' '}
Contribution incentives are available (e.g., cash
bounties, badges & shout-outs, priority
review/merge, beta access). Prefer tasks with
<a
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
bounty label
</a>
, or file a
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
Bounty Claim
</a>
after completion.
</div>
)}
</div>
</div>
) : item.id === 'contribute-pr-guidelines' ? (
<div className="space-y-3">
<div className="text-base">
{language === 'zh' ? '参考文档:' : 'References:'}{' '}
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
CONTRIBUTING.md
</a>
{' | '}
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/PR_TITLE_GUIDE.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
PR_TITLE_GUIDE.md
</a>
</div>
<ol className="list-decimal pl-5 space-y-1 text-base">
{language === 'zh' ? (
<>
<li>
Fork fork <code>dev</code>{' '}
<code>main</code>{' '}
</li>
<li>
feat/fix/docs/
Conventional Commits
</li>
<li>
<code className="ml-2">
npm --prefix web run lint && npm --prefix web
run build
</code>
</li>
<li> UI </li>
<li>
PR
frontend/backend/docs/general
</li>
<li>
PR Issue
<code className="ml-1">Closes #123</code>PR
<code>NoFxAiOS/nofx:dev</code>
</li>
<li>
<code>upstream/dev</code>{' '}
rebase CI PR
</li>
</>
) : (
<>
<li>
After forking, branch from your fork's{' '}
<code>dev</code>; avoid direct commits to upstream{' '}
<code>main</code>.
</li>
<li>
Branch naming: feat/…, fix/…, docs/…; commit
messages follow Conventional Commits.
</li>
<li>
Run checks before PR:
<code className="ml-2">
npm --prefix web run lint && npm --prefix web
run build
</code>
</li>
<li>
For UI changes, attach screenshots or a short
video.
</li>
<li>
Choose the proper PR template
(frontend/backend/docs/general).
</li>
<li>
Link the Issue in PR (e.g.,{' '}
<code className="ml-1">Closes #123</code>) and
target <code>NoFxAiOS/nofx:dev</code>.
</li>
<li>
Keep rebasing onto <code>upstream/dev</code>,
ensure CI passes; prefer small and focused PRs.
</li>
</>
)}
</ol>
<div
className="rounded p-3 mt-3"
style={{
background: 'rgba(240, 185, 11, 0.08)',
border: '1px solid rgba(240, 185, 11, 0.25)',
}}
>
{language === 'zh' ? (
<div className="text-sm">
<strong style={{ color: '#F0B90B' }}>提示:</strong>{' '}
我们为高质量贡献提供激励Bounty/奖金、荣誉徽章与鸣谢、优先
Review/合并与内测资格 等)。 详情可关注带
<a
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
bounty 标签
</a>
的任务,或使用
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
Bounty Claim 模板
</a>
提交申请。
</div>
) : (
<div className="text-sm">
<strong style={{ color: '#F0B90B' }}>Note:</strong>{' '}
We offer contribution incentives (bounties, badges,
shout-outs, priority review/merge, beta access).
Look for tasks with
<a
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
bounty label
</a>
, or submit a
<a
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
target="_blank"
rel="noreferrer"
style={{ color: '#F0B90B' }}
>
Bounty Claim
</a>
when ready.
</div>
)}
</div>
</div>
) : (
<p className="text-base">{t(item.answerKey, language)}</p>
)}
</div>
{/* Divider */}
<div className="mt-6 h-px" style={{ background: '#2B3139' }} />
</section>
))}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,182 @@
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'
import { FAQContent } from './FAQContent'
import { faqCategories } from '../../data/faqData'
import type { FAQCategory } from '../../data/faqData'
interface FAQLayoutProps {
language: Language
}
export function FAQLayout({ language }: FAQLayoutProps) {
const [searchTerm, setSearchTerm] = useState('')
const [activeItemId, setActiveItemId] = useState<string | null>(null)
// Filter categories based on search term
const filteredCategories = useMemo(() => {
if (!searchTerm.trim()) {
return faqCategories
}
const term = searchTerm.toLowerCase()
const filtered: FAQCategory[] = []
faqCategories.forEach((category) => {
const matchingItems = category.items.filter((item) => {
const question = t(item.questionKey, language).toLowerCase()
const answer = t(item.answerKey, language).toLowerCase()
return question.includes(term) || answer.includes(term)
})
if (matchingItems.length > 0) {
filtered.push({
...category,
items: matchingItems,
})
}
})
return filtered
}, [searchTerm, language])
const handleItemClick = (_categoryId: string, itemId: string) => {
const element = document.getElementById(itemId)
if (element) {
const offset = 100
const elementPosition = element.getBoundingClientRect().top
const offsetPosition = elementPosition + window.pageYOffset - offset
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
})
}
}
return (
<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">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 8px 24px rgba(240, 185, 11, 0.4)',
}}
>
<HelpCircle className="w-8 h-8" style={{ color: '#0B0E11' }} />
</div>
</div>
<h1 className="text-4xl font-bold mb-4" style={{ color: '#EAECEF' }}>
{t('faqTitle', language)}
</h1>
<p className="text-lg mb-8" style={{ color: '#848E9C' }}>
{t('faqSubtitle', language)}
</p>
{/* Search Bar */}
<div className="max-w-2xl mx-auto">
<FAQSearchBar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
placeholder={
language === 'zh' ? '搜索常见问题...' : 'Search FAQ...'
}
/>
</div>
</div>
{/* Main Content */}
<div className="flex gap-8">
{/* Sidebar - Hidden on mobile, visible on desktop */}
<aside className="hidden lg:block w-64 flex-shrink-0">
<FAQSidebar
categories={filteredCategories}
activeItemId={activeItemId}
language={language}
onItemClick={handleItemClick}
/>
</aside>
{/* Content Area */}
<main className="flex-1 min-w-0">
{filteredCategories.length > 0 ? (
<FAQContent
categories={filteredCategories}
language={language}
onActiveItemChange={setActiveItemId}
/>
) : (
<div className="text-center py-12">
<p className="text-lg" style={{ color: '#848E9C' }}>
{language === 'zh'
? '没有找到匹配的问题'
: 'No matching questions found'}
</p>
<button
onClick={() => setSearchTerm('')}
className="mt-4 px-6 py-2 rounded-lg font-semibold transition-all hover:opacity-90"
style={{
background:
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
color: '#0B0E11',
}}
>
{language === 'zh' ? '清除搜索' : 'Clear Search'}
</button>
</div>
)}
</main>
</div>
{/* Contact Section */}
<div
className="mt-16 p-8 rounded-lg text-center"
style={{
background:
'linear-gradient(135deg, rgba(240, 185, 11, 0.1) 0%, rgba(252, 213, 53, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
<h3 className="text-xl font-bold mb-3" style={{ color: '#EAECEF' }}>
{t('faqStillHaveQuestions', language)}
</h3>
<p className="mb-6" style={{ color: '#848E9C' }}>
{t('faqContactUs', language)}
</p>
<div className="flex items-center justify-center gap-4">
<a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
style={{
background: '#1E2329',
color: '#EAECEF',
border: '1px solid #2B3139',
}}
>
GitHub
</a>
<a
href="https://t.me/nofx_dev_community"
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
color: '#0B0E11',
}}
>
{t('community', language)}
</a>
</div>
</div>
</Container>
)
}

View File

@@ -0,0 +1,51 @@
import { Search, X } from 'lucide-react'
interface FAQSearchBarProps {
searchTerm: string
onSearchChange: (value: string) => void
placeholder?: string
}
export function FAQSearchBar({
searchTerm,
onSearchChange,
placeholder = 'Search FAQ...',
}: FAQSearchBarProps) {
return (
<div className="relative">
<Search
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5"
style={{ color: '#848E9C' }}
/>
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none focus:ring-2"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
onFocus={(e) => {
e.target.style.borderColor = '#F0B90B'
e.target.style.boxShadow = '0 0 0 3px rgba(240, 185, 11, 0.1)'
}}
onBlur={(e) => {
e.target.style.borderColor = '#2B3139'
e.target.style.boxShadow = 'none'
}}
/>
{searchTerm && (
<button
onClick={() => onSearchChange('')}
className="absolute right-4 top-1/2 transform -translate-y-1/2 hover:opacity-70 transition-opacity"
style={{ color: '#848E9C' }}
>
<X className="w-5 h-5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { t, type Language } from '../../i18n/translations'
import type { FAQCategory } from '../../data/faqData'
interface FAQSidebarProps {
categories: FAQCategory[]
activeItemId: string | null
language: Language
onItemClick: (categoryId: string, itemId: string) => void
}
export function FAQSidebar({
categories,
activeItemId,
language,
onItemClick,
}: FAQSidebarProps) {
return (
<nav
className="sticky top-24 h-[calc(100vh-120px)] overflow-y-auto pr-4"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#2B3139 #1E2329',
}}
>
<div className="space-y-6">
{categories.map((category) => (
<div key={category.id}>
{/* Category Title */}
<div className="flex items-center gap-2 mb-3 px-3">
<category.icon className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3
className="text-sm font-bold uppercase tracking-wide"
style={{ color: '#F0B90B' }}
>
{t(category.titleKey, language)}
</h3>
</div>
{/* Category Items */}
<ul className="space-y-1">
{category.items.map((item) => {
const isActive = activeItemId === item.id
return (
<li key={item.id}>
<button
onClick={() => onItemClick(category.id, item.id)}
className="w-full text-left px-3 py-2 rounded-lg text-sm transition-all"
style={{
background: isActive
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
color: isActive ? '#F0B90B' : '#848E9C',
borderLeft: isActive
? '3px solid #F0B90B'
: '3px solid transparent',
paddingLeft: isActive ? '9px' : '12px',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background =
'rgba(240, 185, 11, 0.05)'
e.currentTarget.style.color = '#EAECEF'
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = '#848E9C'
}
}}
>
{t(item.questionKey, language)}
</button>
</li>
)
})}
</ul>
</div>
))}
</div>
</nav>
)
}

View File

@@ -121,7 +121,7 @@ export default function FooterSection({ language }: FooterSectionProps) {
<li>
<a
className="hover:text-[#F0B90B]"
href="https://asterdex.com/"
href="https://www.asterdex.com/en/referral/fdfc0e"
target="_blank"
rel="noopener noreferrer"
>
@@ -131,7 +131,7 @@ export default function FooterSection({ language }: FooterSectionProps) {
<li>
<a
className="hover:text-[#F0B90B]"
href="https://www.binance.com/"
href="https://www.maxweb.red/join?ref=NOFXAI"
target="_blank"
rel="noopener noreferrer"
>

View 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,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '../../lib/cn'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(
'flex h-10 w-full rounded px-3 py-2 text-sm',
'bg-[var(--brand-black)] border border-[var(--panel-border)]',
'text-[var(--brand-light-gray)] focus:outline-none',
className
)}
{...props}
/>
)
}
)
Input.displayName = 'Input'