mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 20:11:13 +08:00
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:
@@ -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
329
web/src/components/CompetitionPage.test.tsx
Normal file
329
web/src/components/CompetitionPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
123
web/src/components/ConfirmDialog.tsx
Normal file
123
web/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog'
|
||||
import { setGlobalConfirm } from '../lib/notify'
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string
|
||||
message: string
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
interface ConfirmDialogContextType {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
const ConfirmDialogContext = createContext<
|
||||
ConfirmDialogContextType | undefined
|
||||
>(undefined)
|
||||
|
||||
export function useConfirmDialog() {
|
||||
const context = useContext(ConfirmDialogContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useConfirmDialog must be used within ConfirmDialogProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
message: string
|
||||
okText: string
|
||||
cancelText: string
|
||||
resolve?: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function ConfirmDialogProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
isOpen: false,
|
||||
message: '',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
})
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setState({
|
||||
isOpen: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
okText: options.okText || '确认',
|
||||
cancelText: options.cancelText || '取消',
|
||||
resolve,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 注册全局 confirm 函数
|
||||
useEffect(() => {
|
||||
setGlobalConfirm(confirm)
|
||||
}, [confirm])
|
||||
|
||||
const handleClose = useCallback((result: boolean) => {
|
||||
setState((prev) => {
|
||||
prev.resolve?.(result)
|
||||
return {
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ConfirmDialogContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<AlertDialog
|
||||
open={state.isOpen}
|
||||
onOpenChange={(open) => !open && handleClose(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-5 text-center">
|
||||
{state.title && (
|
||||
<AlertDialogTitle className="text-xl">
|
||||
{state.title}
|
||||
</AlertDialogTitle>
|
||||
)}
|
||||
<AlertDialogDescription className="text-[var(--text-primary)] text-base font-medium">
|
||||
{state.message}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => handleClose(false)}>
|
||||
{state.cancelText}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleClose(true)}>
|
||||
{state.okText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ConfirmDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
40
web/src/components/Container.tsx
Normal file
40
web/src/components/Container.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ReactNode, CSSProperties } from 'react'
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
as?: 'div' | 'main' | 'header' | 'section'
|
||||
style?: CSSProperties
|
||||
/** 是否充满宽度(取消 max-width) */
|
||||
fluid?: boolean
|
||||
/** 是否取消水平内边距 */
|
||||
noPadding?: boolean
|
||||
/** 自定义最大宽度类(默认 max-w-[1920px]) */
|
||||
maxWidthClass?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的容器组件,确保所有页面元素使用一致的最大宽度和内边距
|
||||
* - max-width: 1920px
|
||||
* - padding: 24px (mobile) -> 32px (tablet) -> 48px (desktop)
|
||||
*/
|
||||
export function Container({
|
||||
children,
|
||||
className = '',
|
||||
as: Component = 'div',
|
||||
style,
|
||||
fluid = false,
|
||||
noPadding = false,
|
||||
maxWidthClass = 'max-w-[1920px]',
|
||||
}: ContainerProps) {
|
||||
const maxWidth = fluid ? 'w-full' : maxWidthClass
|
||||
const padding = noPadding ? 'px-0' : 'px-6 sm:px-8 lg:px-12'
|
||||
return (
|
||||
<Component
|
||||
className={`${maxWidth} mx-auto ${padding} ${className}`}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
116
web/src/components/DevToastController.tsx
Normal file
116
web/src/components/DevToastController.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { useState } from 'react'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
|
||||
const toastOptions = [
|
||||
'message',
|
||||
'success',
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
'custom',
|
||||
] as const
|
||||
|
||||
type ToastType = (typeof toastOptions)[number]
|
||||
|
||||
const customRenderer = () => (
|
||||
<div className="dev-custom-toast">
|
||||
<p className="dev-custom-title">Sonner 自定义通知</p>
|
||||
<p className="dev-custom-body">
|
||||
这是一个通过 `notify.custom` 渲染的测试 Toast
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
export function DevToastController() {
|
||||
const [type, setType] = useState<ToastType>('success')
|
||||
const [message, setMessage] = useState('来自 Dev 控制器的测试通知')
|
||||
const [duration, setDuration] = useState(2200)
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
return null
|
||||
}
|
||||
|
||||
const triggerToast = async () => {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
notify.message(message, { duration })
|
||||
break
|
||||
case 'success':
|
||||
notify.success(message, { duration })
|
||||
break
|
||||
case 'info':
|
||||
notify.info(message, { duration })
|
||||
break
|
||||
case 'warning':
|
||||
notify.warning(message, { duration })
|
||||
break
|
||||
case 'error':
|
||||
notify.error(message, { duration })
|
||||
break
|
||||
case 'custom':
|
||||
notify.custom(() => customRenderer(), { duration })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const triggerConfirm = async () => {
|
||||
const confirmed = await confirmToast(message, {
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
})
|
||||
if (confirmed) {
|
||||
notify.success('确认按钮已点击', { duration: 2000 })
|
||||
} else {
|
||||
notify.message('已取消确认逻辑', { duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dev-toast-controller">
|
||||
<div className="dev-toast-controller__header">
|
||||
<span>Dev Sonner 控制器</span>
|
||||
<small>仅在 dev 模式可见</small>
|
||||
</div>
|
||||
<div className="dev-toast-controller__content">
|
||||
<label className="dev-toast-controller__label">
|
||||
类型
|
||||
<select
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as ToastType)}
|
||||
>
|
||||
{toastOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dev-toast-controller__label">
|
||||
文案
|
||||
<input
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
placeholder="输入通知/确认文案"
|
||||
/>
|
||||
</label>
|
||||
<label className="dev-toast-controller__label">
|
||||
持续(ms)
|
||||
<input
|
||||
type="number"
|
||||
min={600}
|
||||
value={duration}
|
||||
onChange={(event) => setDuration(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="dev-toast-controller__actions">
|
||||
<button onClick={triggerToast}>触发通知</button>
|
||||
<button onClick={triggerConfirm}>触发确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DevToastController
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { Container } from './Container'
|
||||
|
||||
interface HeaderProps {
|
||||
simple?: boolean // For login/register pages
|
||||
@@ -10,7 +11,7 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
|
||||
return (
|
||||
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
|
||||
<div className="max-w-[1920px] mx-auto px-6 py-4">
|
||||
<Container className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left - Logo and Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -58,7 +59,7 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
921
web/src/components/HeaderBar.tsx
Normal file
921
web/src/components/HeaderBar.tsx
Normal file
@@ -0,0 +1,921 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { Container } from './Container'
|
||||
|
||||
interface HeaderBarProps {
|
||||
onLoginClick?: () => void
|
||||
isLoggedIn?: boolean
|
||||
isHomePage?: boolean
|
||||
currentPage?: string
|
||||
language?: Language
|
||||
onLanguageChange?: (lang: Language) => void
|
||||
user?: { email: string } | null
|
||||
onLogout?: () => void
|
||||
onPageChange?: (page: string) => void
|
||||
}
|
||||
|
||||
export default function HeaderBar({
|
||||
isLoggedIn = false,
|
||||
isHomePage = false,
|
||||
currentPage,
|
||||
language = 'zh' as Language,
|
||||
onLanguageChange,
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
}: HeaderBarProps) {
|
||||
const navigate = useNavigate()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setLanguageDropdownOpen(false)
|
||||
}
|
||||
if (
|
||||
userDropdownRef.current &&
|
||||
!userDropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setUserDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||
<Container className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
NOFX
|
||||
</span>
|
||||
<span
|
||||
className="text-sm hidden sm:block"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Agentic Trading OS
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||
{/* Left Side - Navigation Tabs */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isLoggedIn ? (
|
||||
// Main app navigation when logged in
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/competition')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/traders')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/dashboard')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('faq')
|
||||
} else {
|
||||
navigate('/faq')
|
||||
}
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'faq'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'faq') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'faq') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'faq' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('faqNav', language)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Landing page navigation when not logged in
|
||||
<>
|
||||
<a
|
||||
href="/competition"
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/faq"
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'faq'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'faq') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'faq') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'faq' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('faqNav', language)}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side - Original Navigation Items and Login */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Only show original navigation items on home page */}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="text-sm transition-colors relative group"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
|
||||
style={{ background: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* User Info and Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* User Info with Dropdown */}
|
||||
<div className="relative" ref={userDropdownRef}>
|
||||
<button
|
||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'var(--panel-bg)')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{userDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 border-b"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout()
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Language Toggle - Always at the rightmost */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'transparent')
|
||||
}
|
||||
>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'zh' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'zh'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'en' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'en'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</motion.button>
|
||||
</Container>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={
|
||||
mobileMenuOpen
|
||||
? { height: 'auto', opacity: 1 }
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="md:hidden overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{/* New Navigation Tabs */}
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'移动端 实时 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('competition')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/competition"
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
)}
|
||||
{/* Only show 配置 and 看板 when logged in */}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('traders')
|
||||
} else {
|
||||
navigate('/traders')
|
||||
}
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('trader')
|
||||
} else {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('faq')
|
||||
} else {
|
||||
navigate('/faq')
|
||||
}
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'faq'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'faq' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('faqNav', language)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Original Navigation Items - Only on home page */}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="block text-sm py-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('language', language)}:
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User info and logout for mobile when logged in */}
|
||||
{isLoggedIn && user && (
|
||||
<div
|
||||
className="mt-4 pt-4"
|
||||
style={{ borderTop: '1px solid var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
||||
style={{ background: 'var(--panel-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
||||
{!isLoggedIn &&
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid var(--brand-light-gray)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,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>
|
||||
)
|
||||
|
||||
377
web/src/components/RegisterPage.test.tsx
Normal file
377
web/src/components/RegisterPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
293
web/src/components/ResetPasswordPage.tsx
Normal file
293
web/src/components/ResetPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]"
|
||||
|
||||
@@ -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('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
347
web/src/components/TwoStageKeyModal.tsx
Normal file
347
web/src/components/TwoStageKeyModal.tsx
Normal 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)
|
||||
}
|
||||
138
web/src/components/WebCryptoEnvironmentCheck.tsx
Normal file
138
web/src/components/WebCryptoEnvironmentCheck.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
459
web/src/components/faq/FAQContent.tsx
Normal file
459
web/src/components/faq/FAQContent.tsx
Normal 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>
|
||||
打开 PR:base 选择 <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>
|
||||
)
|
||||
}
|
||||
182
web/src/components/faq/FAQLayout.tsx
Normal file
182
web/src/components/faq/FAQLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
web/src/components/faq/FAQSearchBar.tsx
Normal file
51
web/src/components/faq/FAQSearchBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
web/src/components/faq/FAQSidebar.tsx
Normal file
83
web/src/components/faq/FAQSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
142
web/src/components/ui/alert-dialog.tsx
Normal file
142
web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--panel-border)] bg-[var(--panel-bg)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-center sm:space-x-2 gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold text-[var(--text-primary)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-[var(--text-secondary)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--binance-yellow)] disabled:pointer-events-none disabled:opacity-50 bg-[var(--binance-yellow)] text-black hover:brightness-95 h-10 px-8 min-w-[140px] shadow-[0_10px_30px_rgba(240,185,11,0.35)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--panel-border)] disabled:pointer-events-none disabled:opacity-50 border border-[var(--panel-border)] bg-transparent text-[var(--text-secondary)] hover:bg-white/5 h-10 px-8 min-w-[140px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
24
web/src/components/ui/input.tsx
Normal file
24
web/src/components/ui/input.tsx
Normal 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'
|
||||
Reference in New Issue
Block a user