mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 12:30:59 +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:
1142
web/src/App.tsx
1142
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import useSWR from 'swr'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { stripLeadingIcons } from '../lib/text'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
Brain,
|
||||
@@ -78,7 +79,9 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
className="rounded p-6"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div style={{ color: '#F6465D' }}>{t('loadingError', language)}</div>
|
||||
<div style={{ color: '#F6465D' }}>
|
||||
{stripLeadingIcons(t('loadingError', language))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -695,7 +698,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
style={{ color: '#E0E7FF' }}
|
||||
>
|
||||
<BarChart3 className="w-5 h-5" />{' '}
|
||||
{t('symbolPerformance', language)}
|
||||
{stripLeadingIcons(t('symbolPerformance', language))}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
@@ -1084,7 +1087,7 @@ export default function AILearning({ traderId }: AILearningProps) {
|
||||
className="font-bold mb-3 text-base"
|
||||
style={{ color: '#FCD34D' }}
|
||||
>
|
||||
{t('howAILearns', language)}
|
||||
{stripLeadingIcons(t('howAILearns', language))}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
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'
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import { reset401Flag } from '../lib/httpClient'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -18,6 +19,10 @@ interface AuthContextType {
|
||||
userID?: string
|
||||
requiresOTP?: boolean
|
||||
}>
|
||||
loginAdmin: (password: string) => Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}>
|
||||
register: (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -37,6 +42,11 @@ interface AuthContextType {
|
||||
userID: string,
|
||||
otpCode: string
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
resetPassword: (
|
||||
email: string,
|
||||
newPassword: string,
|
||||
otpCode: string
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
@@ -49,23 +59,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset 401 flag on page load to allow fresh 401 handling
|
||||
reset401Flag()
|
||||
|
||||
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
|
||||
getSystemConfig()
|
||||
.then((data) => {
|
||||
if (data.admin_mode) {
|
||||
// 管理员模式下,模拟admin用户
|
||||
setUser({ id: 'admin', email: 'admin@localhost' })
|
||||
setToken('admin-mode')
|
||||
} else {
|
||||
// 非管理员模式,检查本地存储中是否有token
|
||||
const savedToken = localStorage.getItem('auth_token')
|
||||
const savedUser = localStorage.getItem('auth_user')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken)
|
||||
setUser(JSON.parse(savedUser))
|
||||
}
|
||||
.then(() => {
|
||||
// 不再在管理员模式下模拟登录;统一检查本地存储
|
||||
const savedToken = localStorage.getItem('auth_token')
|
||||
const savedUser = localStorage.getItem('auth_user')
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken)
|
||||
setUser(JSON.parse(savedUser))
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -82,6 +89,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Listen for unauthorized events from httpClient (401 responses)
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
console.log('Unauthorized event received - clearing auth state')
|
||||
// Clear auth state when 401 is detected
|
||||
setUser(null)
|
||||
setToken(null)
|
||||
// Note: localStorage cleanup is already done in httpClient
|
||||
}
|
||||
|
||||
window.addEventListener('unauthorized', handleUnauthorized)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unauthorized', handleUnauthorized)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
@@ -113,6 +137,47 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return { success: false, message: '未知错误' }
|
||||
}
|
||||
|
||||
const loginAdmin = async (password: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/admin-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.ok) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
const userInfo = {
|
||||
id: data.user_id || 'admin',
|
||||
email: data.email || 'admin@localhost',
|
||||
}
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// 跳转到仪表盘
|
||||
window.history.pushState({}, '', '/dashboard')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, message: data.error || '登录失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, message: '登录失败,请重试' }
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -167,6 +232,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
// 登录成功,保存token和用户信息
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
@@ -174,9 +242,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return { success: true, message: data.message }
|
||||
} else {
|
||||
@@ -200,6 +276,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
// 注册完成,自动登录
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
@@ -207,9 +286,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// 跳转到配置页面
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return { success: true, message: data.message }
|
||||
} else {
|
||||
@@ -220,7 +307,46 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (
|
||||
email: string,
|
||||
newPassword: string,
|
||||
otpCode: string
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch('/api/reset-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
new_password: newPassword,
|
||||
otp_code: otpCode,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, message: data.message }
|
||||
} else {
|
||||
return { success: false, message: data.error }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '密码重置失败,请重试' }
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
const savedToken = localStorage.getItem('auth_token')
|
||||
if (savedToken) {
|
||||
fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
}).catch(() => {
|
||||
/* ignore network errors on logout */
|
||||
})
|
||||
}
|
||||
setUser(null)
|
||||
setToken(null)
|
||||
localStorage.removeItem('auth_token')
|
||||
@@ -233,9 +359,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
loginAdmin,
|
||||
register,
|
||||
verifyOTP,
|
||||
completeRegistration,
|
||||
resetPassword,
|
||||
logout,
|
||||
isLoading,
|
||||
}}
|
||||
|
||||
268
web/src/data/faqData.ts
Normal file
268
web/src/data/faqData.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Wrench,
|
||||
Bot,
|
||||
Database,
|
||||
GitBranch,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface FAQItem {
|
||||
id: string
|
||||
questionKey: string
|
||||
answerKey: string
|
||||
}
|
||||
|
||||
export interface FAQCategory {
|
||||
id: string
|
||||
titleKey: string
|
||||
icon: LucideIcon
|
||||
items: FAQItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 数据配置
|
||||
* - titleKey: 分类标题的翻译键
|
||||
* - questionKey: 问题的翻译键
|
||||
* - answerKey: 答案的翻译键
|
||||
*
|
||||
* 所有文本内容都通过翻译键从 i18n/translations.ts 获取
|
||||
*/
|
||||
export const faqCategories: FAQCategory[] = [
|
||||
{
|
||||
id: 'basics',
|
||||
titleKey: 'faqCategoryBasics',
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
id: 'what-is-nofx',
|
||||
questionKey: 'faqWhatIsNOFX',
|
||||
answerKey: 'faqWhatIsNOFXAnswer',
|
||||
},
|
||||
{
|
||||
id: 'supported-exchanges',
|
||||
questionKey: 'faqSupportedExchanges',
|
||||
answerKey: 'faqSupportedExchangesAnswer',
|
||||
},
|
||||
{
|
||||
id: 'is-profitable',
|
||||
questionKey: 'faqIsProfitable',
|
||||
answerKey: 'faqIsProfitableAnswer',
|
||||
},
|
||||
{
|
||||
id: 'multiple-traders',
|
||||
questionKey: 'faqMultipleTraders',
|
||||
answerKey: 'faqMultipleTradersAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contributing',
|
||||
titleKey: 'faqCategoryContributing',
|
||||
icon: GitBranch,
|
||||
items: [
|
||||
{
|
||||
id: 'github-projects-tasks',
|
||||
questionKey: 'faqGithubProjectsTasks',
|
||||
answerKey: 'faqGithubProjectsTasksAnswer',
|
||||
},
|
||||
{
|
||||
id: 'contribute-pr-guidelines',
|
||||
questionKey: 'faqContributePR',
|
||||
answerKey: 'faqContributePRAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'setup',
|
||||
titleKey: 'faqCategorySetup',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
id: 'system-requirements',
|
||||
questionKey: 'faqSystemRequirements',
|
||||
answerKey: 'faqSystemRequirementsAnswer',
|
||||
},
|
||||
{
|
||||
id: 'need-coding',
|
||||
questionKey: 'faqNeedCoding',
|
||||
answerKey: 'faqNeedCodingAnswer',
|
||||
},
|
||||
{
|
||||
id: 'get-api-keys',
|
||||
questionKey: 'faqGetApiKeys',
|
||||
answerKey: 'faqGetApiKeysAnswer',
|
||||
},
|
||||
{
|
||||
id: 'use-subaccount',
|
||||
questionKey: 'faqUseSubaccount',
|
||||
answerKey: 'faqUseSubaccountAnswer',
|
||||
},
|
||||
{
|
||||
id: 'docker-deployment',
|
||||
questionKey: 'faqDockerDeployment',
|
||||
answerKey: 'faqDockerDeploymentAnswer',
|
||||
},
|
||||
{
|
||||
id: 'balance-shows-zero',
|
||||
questionKey: 'faqBalanceZero',
|
||||
answerKey: 'faqBalanceZeroAnswer',
|
||||
},
|
||||
{
|
||||
id: 'testnet-issues',
|
||||
questionKey: 'faqTestnet',
|
||||
answerKey: 'faqTestnetAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'trading',
|
||||
titleKey: 'faqCategoryTrading',
|
||||
icon: TrendingUp,
|
||||
items: [
|
||||
{
|
||||
id: 'no-trades',
|
||||
questionKey: 'faqNoTrades',
|
||||
answerKey: 'faqNoTradesAnswer',
|
||||
},
|
||||
{
|
||||
id: 'decision-frequency',
|
||||
questionKey: 'faqDecisionFrequency',
|
||||
answerKey: 'faqDecisionFrequencyAnswer',
|
||||
},
|
||||
{
|
||||
id: 'custom-strategy',
|
||||
questionKey: 'faqCustomStrategy',
|
||||
answerKey: 'faqCustomStrategyAnswer',
|
||||
},
|
||||
{
|
||||
id: 'max-positions',
|
||||
questionKey: 'faqMaxPositions',
|
||||
answerKey: 'faqMaxPositionsAnswer',
|
||||
},
|
||||
{
|
||||
id: 'margin-insufficient',
|
||||
questionKey: 'faqMarginInsufficient',
|
||||
answerKey: 'faqMarginInsufficientAnswer',
|
||||
},
|
||||
{
|
||||
id: 'high-fees',
|
||||
questionKey: 'faqHighFees',
|
||||
answerKey: 'faqHighFeesAnswer',
|
||||
},
|
||||
{
|
||||
id: 'no-take-profit',
|
||||
questionKey: 'faqNoTakeProfit',
|
||||
answerKey: 'faqNoTakeProfitAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'technical',
|
||||
titleKey: 'faqCategoryTechnical',
|
||||
icon: Wrench,
|
||||
items: [
|
||||
{
|
||||
id: 'binance-api-failed',
|
||||
questionKey: 'faqBinanceApiFailed',
|
||||
answerKey: 'faqBinanceApiFailedAnswer',
|
||||
},
|
||||
{
|
||||
id: 'binance-position-mode',
|
||||
questionKey: 'faqBinancePositionMode',
|
||||
answerKey: 'faqBinancePositionModeAnswer',
|
||||
},
|
||||
{
|
||||
id: 'port-in-use',
|
||||
questionKey: 'faqPortInUse',
|
||||
answerKey: 'faqPortInUseAnswer',
|
||||
},
|
||||
{
|
||||
id: 'frontend-loading',
|
||||
questionKey: 'faqFrontendLoading',
|
||||
answerKey: 'faqFrontendLoadingAnswer',
|
||||
},
|
||||
{
|
||||
id: 'database-locked',
|
||||
questionKey: 'faqDatabaseLocked',
|
||||
answerKey: 'faqDatabaseLockedAnswer',
|
||||
},
|
||||
{
|
||||
id: 'ai-learning-failed',
|
||||
questionKey: 'faqAiLearningFailed',
|
||||
answerKey: 'faqAiLearningFailedAnswer',
|
||||
},
|
||||
{
|
||||
id: 'config-not-effective',
|
||||
questionKey: 'faqConfigNotEffective',
|
||||
answerKey: 'faqConfigNotEffectiveAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
titleKey: 'faqCategoryAI',
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
id: 'which-models',
|
||||
questionKey: 'faqWhichModels',
|
||||
answerKey: 'faqWhichModelsAnswer',
|
||||
},
|
||||
{
|
||||
id: 'api-costs',
|
||||
questionKey: 'faqApiCosts',
|
||||
answerKey: 'faqApiCostsAnswer',
|
||||
},
|
||||
{
|
||||
id: 'multiple-models',
|
||||
questionKey: 'faqMultipleModels',
|
||||
answerKey: 'faqMultipleModelsAnswer',
|
||||
},
|
||||
{
|
||||
id: 'ai-learning',
|
||||
questionKey: 'faqAiLearning',
|
||||
answerKey: 'faqAiLearningAnswer',
|
||||
},
|
||||
{
|
||||
id: 'only-short-positions',
|
||||
questionKey: 'faqOnlyShort',
|
||||
answerKey: 'faqOnlyShortAnswer',
|
||||
},
|
||||
{
|
||||
id: 'model-selection',
|
||||
questionKey: 'faqModelSelection',
|
||||
answerKey: 'faqModelSelectionAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
titleKey: 'faqCategoryData',
|
||||
icon: Database,
|
||||
items: [
|
||||
{
|
||||
id: 'data-storage',
|
||||
questionKey: 'faqDataStorage',
|
||||
answerKey: 'faqDataStorageAnswer',
|
||||
},
|
||||
{
|
||||
id: 'api-key-security',
|
||||
questionKey: 'faqApiKeySecurity',
|
||||
answerKey: 'faqApiKeySecurityAnswer',
|
||||
},
|
||||
{
|
||||
id: 'export-history',
|
||||
questionKey: 'faqExportHistory',
|
||||
answerKey: 'faqExportHistoryAnswer',
|
||||
},
|
||||
{
|
||||
id: 'get-help',
|
||||
questionKey: 'faqGetHelp',
|
||||
answerKey: 'faqGetHelpAnswer',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -20,6 +20,7 @@ export const translations = {
|
||||
realtimeNav: 'Live',
|
||||
configNav: 'Config',
|
||||
dashboardNav: 'Dashboard',
|
||||
faqNav: 'FAQ',
|
||||
|
||||
// Footer
|
||||
footerTitle: 'NOFX - AI Trading System',
|
||||
@@ -145,6 +146,10 @@ export const translations = {
|
||||
currentTraders: 'Current Traders',
|
||||
noTraders: 'No AI Traders',
|
||||
createFirstTrader: 'Create your first AI trader to get started',
|
||||
dashboardEmptyTitle: "Let's Get Started!",
|
||||
dashboardEmptyDescription:
|
||||
'Create your first AI trader to automate your trading strategy. Connect an exchange, choose an AI model, and start trading in minutes!',
|
||||
goToTradersPage: 'Create Your First Trader',
|
||||
configureModelsFirst: 'Please configure AI models first',
|
||||
configureExchangesFirst: 'Please configure exchanges first',
|
||||
configureModelsAndExchangesFirst:
|
||||
@@ -197,6 +202,18 @@ export const translations = {
|
||||
'Hyperliquid uses private key for trading authentication',
|
||||
hyperliquidWalletAddressDesc:
|
||||
'Wallet address corresponding to the private key',
|
||||
// Hyperliquid Agent Wallet (New Security Model)
|
||||
hyperliquidAgentWalletTitle: 'Hyperliquid Agent Wallet Configuration',
|
||||
hyperliquidAgentWalletDesc:
|
||||
'Use Agent Wallet for secure trading: Agent wallet signs transactions (balance ~0), Main wallet holds funds (never expose private key)',
|
||||
hyperliquidAgentPrivateKey: 'Agent Private Key',
|
||||
enterHyperliquidAgentPrivateKey: 'Enter Agent wallet private key',
|
||||
hyperliquidAgentPrivateKeyDesc:
|
||||
'Agent wallet private key for signing transactions (keep balance near 0 for security)',
|
||||
hyperliquidMainWalletAddress: 'Main Wallet Address',
|
||||
enterHyperliquidMainWalletAddress: 'Enter Main wallet address',
|
||||
hyperliquidMainWalletAddressDesc:
|
||||
'Main wallet address that holds your trading funds (never expose its private key)',
|
||||
asterUserDesc:
|
||||
'Main wallet address - The EVM wallet address you use to log in to Aster (Note: Only EVM wallets are supported, Solana wallets are not supported)',
|
||||
asterSignerDesc:
|
||||
@@ -205,6 +222,44 @@ export const translations = {
|
||||
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
|
||||
asterUsdtWarning:
|
||||
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
|
||||
|
||||
// Exchange names
|
||||
hyperliquidExchangeName: 'Hyperliquid',
|
||||
asterExchangeName: 'Aster DEX',
|
||||
|
||||
// Secure input
|
||||
secureInputButton: 'Secure Input',
|
||||
secureInputReenter: 'Re-enter Securely',
|
||||
secureInputClear: 'Clear',
|
||||
secureInputHint:
|
||||
'Captured via secure two-step input. Use "Re-enter Securely" to update this value.',
|
||||
|
||||
// Two Stage Key Modal
|
||||
twoStageModalTitle: 'Secure Key Input',
|
||||
twoStageModalDescription:
|
||||
'Use a two-step flow to enter your {length}-character private key safely.',
|
||||
twoStageStage1Title: 'Step 1 · Enter the first half',
|
||||
twoStageStage1Placeholder: 'First 32 characters (include 0x if present)',
|
||||
twoStageStage1Hint:
|
||||
'Continuing copies an obfuscation string to your clipboard as a diversion.',
|
||||
twoStageStage1Error: 'Please enter the first part before continuing.',
|
||||
twoStageNext: 'Next',
|
||||
twoStageProcessing: 'Processing…',
|
||||
twoStageCancel: 'Cancel',
|
||||
twoStageStage2Title: 'Step 2 · Enter the rest',
|
||||
twoStageStage2Placeholder: 'Remaining characters of your private key',
|
||||
twoStageStage2Hint:
|
||||
'Paste the obfuscation string somewhere neutral, then finish entering your key.',
|
||||
twoStageClipboardSuccess:
|
||||
'Obfuscation string copied. Paste it into any text field once before completing.',
|
||||
twoStageClipboardReminder:
|
||||
'Remember to paste the obfuscation string before submitting to avoid clipboard leaks.',
|
||||
twoStageClipboardManual:
|
||||
'Automatic copy failed. Copy the obfuscation string below manually.',
|
||||
twoStageBack: 'Back',
|
||||
twoStageSubmit: 'Confirm',
|
||||
twoStageInvalidFormat:
|
||||
'Invalid private key format. Expected {length} hexadecimal characters (optional 0x prefix).',
|
||||
testnetDescription:
|
||||
'Enable to connect to exchange test environment for simulated trading',
|
||||
securityWarning: 'Security Warning',
|
||||
@@ -237,6 +292,33 @@ export const translations = {
|
||||
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
|
||||
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
|
||||
|
||||
// System Prompt Templates
|
||||
systemPromptTemplate: 'System Prompt Template',
|
||||
promptTemplateDefault: 'Default Stable',
|
||||
promptTemplateAdaptive: 'Conservative Strategy',
|
||||
promptTemplateAdaptiveRelaxed: 'Aggressive Strategy',
|
||||
promptTemplateHansen: 'Hansen Strategy',
|
||||
promptTemplateNof1: 'NoF1 English Framework',
|
||||
promptTemplateTaroLong: 'Taro Long Position',
|
||||
promptDescDefault: '📊 Default Stable Strategy',
|
||||
promptDescDefaultContent:
|
||||
'Maximize Sharpe ratio, balanced risk-reward, suitable for beginners and stable long-term trading',
|
||||
promptDescAdaptive: '🛡️ Conservative Strategy (v6.0.0)',
|
||||
promptDescAdaptiveContent:
|
||||
'Strict risk control, BTC mandatory confirmation, high win rate priority, suitable for conservative traders',
|
||||
promptDescAdaptiveRelaxed: '⚡ Aggressive Strategy (v6.0.0)',
|
||||
promptDescAdaptiveRelaxedContent:
|
||||
'High-frequency trading, BTC optional confirmation, pursue trading opportunities, suitable for volatile markets',
|
||||
promptDescHansen: '🎯 Hansen Strategy',
|
||||
promptDescHansenContent:
|
||||
'Hansen custom strategy, maximize Sharpe ratio, for professional traders',
|
||||
promptDescNof1: '🌐 NoF1 English Framework',
|
||||
promptDescNof1Content:
|
||||
'Hyperliquid exchange specialist, English prompts, maximize risk-adjusted returns',
|
||||
promptDescTaroLong: '📈 Taro Long Position Strategy',
|
||||
promptDescTaroLongContent:
|
||||
'Data-driven decisions, multi-dimensional validation, continuous learning evolution, long position specialist',
|
||||
|
||||
// Loading & Error
|
||||
loading: 'Loading...',
|
||||
loadingError: '⚠️ Failed to load AI learning data',
|
||||
@@ -264,6 +346,11 @@ export const translations = {
|
||||
addAIModel: 'Add AI Model',
|
||||
confirmDeleteModel:
|
||||
'Are you sure you want to delete this AI model configuration?',
|
||||
cannotDeleteModelInUse:
|
||||
'Cannot delete this AI model because it is being used by traders',
|
||||
tradersUsing: 'Traders using this configuration',
|
||||
pleaseDeleteTradersFirst:
|
||||
'Please delete or reconfigure these traders first',
|
||||
selectModel: 'Select AI Model',
|
||||
pleaseSelectModel: 'Please select a model',
|
||||
customBaseURL: 'Base URL (Optional)',
|
||||
@@ -280,6 +367,8 @@ export const translations = {
|
||||
addExchange: 'Add Exchange',
|
||||
confirmDeleteExchange:
|
||||
'Are you sure you want to delete this exchange configuration?',
|
||||
cannotDeleteExchangeInUse:
|
||||
'Cannot delete this exchange because it is being used by traders',
|
||||
pleaseSelectExchange: 'Please select an exchange',
|
||||
exchangeConfigWarning1:
|
||||
'• API keys will be encrypted, recommend using read-only or futures trading permissions',
|
||||
@@ -296,6 +385,7 @@ export const translations = {
|
||||
serverIPAddresses: 'Server IP Addresses',
|
||||
copyIP: 'Copy',
|
||||
ipCopied: 'IP Copied',
|
||||
copyIPFailed: 'Failed to copy IP address. Please copy manually',
|
||||
loadingServerIP: 'Loading server IP...',
|
||||
|
||||
// Error Messages
|
||||
@@ -313,16 +403,28 @@ export const translations = {
|
||||
exchangeNotExist: 'Exchange does not exist',
|
||||
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
|
||||
saveSignalSourceFailed: 'Failed to save signal source configuration',
|
||||
encryptionFailed: 'Failed to encrypt sensitive data',
|
||||
|
||||
// Login & Register
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
username: 'Username',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
usernamePlaceholder: 'your username',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
confirmPasswordPlaceholder: 'Re-enter your password',
|
||||
passwordRequirements: 'Password requirements',
|
||||
passwordRuleMinLength: 'Minimum 8 characters',
|
||||
passwordRuleUppercase: 'At least 1 uppercase letter',
|
||||
passwordRuleLowercase: 'At least 1 lowercase letter',
|
||||
passwordRuleNumber: 'At least 1 number',
|
||||
passwordRuleSpecial: 'At least 1 special character (@#$%!&*?)',
|
||||
passwordRuleMatch: 'Passwords match',
|
||||
passwordNotMeetRequirements:
|
||||
'Password does not meet the security requirements',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: 'Sign in to your account',
|
||||
registerTitle: 'Create a new account',
|
||||
@@ -336,6 +438,15 @@ export const translations = {
|
||||
forgotPassword: 'Forgot password?',
|
||||
rememberMe: 'Remember me',
|
||||
otpCode: 'OTP Code',
|
||||
resetPassword: 'Reset Password',
|
||||
resetPasswordTitle: 'Reset your password',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'Enter new password (at least 6 characters)',
|
||||
resetPasswordButton: 'Reset Password',
|
||||
resetPasswordSuccess:
|
||||
'Password reset successful! Please login with your new password',
|
||||
resetPasswordFailed: 'Password reset failed',
|
||||
backToLogin: 'Back to Login',
|
||||
scanQRCode: 'Scan QR Code',
|
||||
enterOTPCode: 'Enter 6-digit OTP code',
|
||||
verifyOTP: 'Verify OTP',
|
||||
@@ -359,10 +470,17 @@ export const translations = {
|
||||
completeRegistrationSubtitle: 'to complete registration',
|
||||
loginSuccess: 'Login successful',
|
||||
registrationSuccess: 'Registration successful',
|
||||
loginFailed: 'Login failed',
|
||||
registrationFailed: 'Registration failed',
|
||||
verificationFailed: 'OTP verification failed',
|
||||
loginFailed: 'Login failed. Please check your email and password.',
|
||||
registrationFailed: 'Registration failed. Please try again.',
|
||||
verificationFailed:
|
||||
'OTP verification failed. Please check the code and try again.',
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
weak: 'Weak',
|
||||
medium: 'Medium',
|
||||
strong: 'Strong',
|
||||
passwordStrength: 'Password strength',
|
||||
passwordStrengthHint:
|
||||
'Use at least 8 characters with mix of letters, numbers and symbols',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
@@ -483,16 +601,249 @@ export const translations = {
|
||||
candidateCoins: 'Candidate Coins',
|
||||
candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
|
||||
possibleReasons: 'Possible Reasons:',
|
||||
coinPoolApiNotConfigured: 'Coin pool API not configured or inaccessible (check signal source settings)',
|
||||
coinPoolApiNotConfigured:
|
||||
'Coin pool API not configured or inaccessible (check signal source settings)',
|
||||
apiConnectionTimeout: 'API connection timeout or returned empty data',
|
||||
noCustomCoinsAndApiFailed: 'No custom coins configured and API fetch failed',
|
||||
noCustomCoinsAndApiFailed:
|
||||
'No custom coins configured and API fetch failed',
|
||||
solutions: 'Solutions:',
|
||||
setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
|
||||
orConfigureCorrectApiUrl: 'Or configure correct coin pool API address',
|
||||
orDisableCoinPoolOptions: 'Or disable "Use Coin Pool" and "Use OI Top" options',
|
||||
orDisableCoinPoolOptions:
|
||||
'Or disable "Use Coin Pool" and "Use OI Top" options',
|
||||
signalSourceNotConfigured: 'Signal Source Not Configured',
|
||||
signalSourceWarningMessage: 'You have traders that enabled "Use Coin Pool" or "Use OI Top", but signal source API address is not configured yet. This will cause candidate coins count to be 0, and traders cannot work properly.',
|
||||
signalSourceWarningMessage:
|
||||
'You have traders that enabled "Use Coin Pool" or "Use OI Top", but signal source API address is not configured yet. This will cause candidate coins count to be 0, and traders cannot work properly.',
|
||||
configureSignalSourceNow: 'Configure Signal Source Now',
|
||||
|
||||
// FAQ Page
|
||||
faqTitle: 'Frequently Asked Questions',
|
||||
faqSubtitle: 'Find answers to common questions about NOFX',
|
||||
faqStillHaveQuestions: 'Still Have Questions?',
|
||||
faqContactUs: 'Join our community or check our GitHub for more help',
|
||||
|
||||
// FAQ Categories
|
||||
faqCategoryBasics: 'General Questions',
|
||||
faqCategoryContributing: 'Contributing & Tasks',
|
||||
faqCategorySetup: 'Setup & Configuration',
|
||||
faqCategoryTrading: 'Trading Questions',
|
||||
faqCategoryTechnical: 'Technical Issues',
|
||||
faqCategoryAI: 'AI & Model Questions',
|
||||
faqCategoryData: 'Data & Privacy',
|
||||
|
||||
// FAQ Questions & Answers - General
|
||||
faqWhatIsNOFX: 'What is NOFX?',
|
||||
faqWhatIsNOFXAnswer:
|
||||
'NOFX is an AI-powered cryptocurrency trading bot that uses large language models (LLMs) to make trading decisions on futures markets.',
|
||||
|
||||
faqSupportedExchanges: 'Which exchanges are supported?',
|
||||
faqSupportedExchangesAnswer:
|
||||
'Binance Futures, Hyperliquid, and Aster DEX are supported. More exchanges coming soon.',
|
||||
|
||||
faqIsProfitable: 'Is NOFX profitable?',
|
||||
faqIsProfitableAnswer:
|
||||
'AI trading is experimental and not guaranteed to be profitable. Always start with small amounts and never invest more than you can afford to lose.',
|
||||
|
||||
faqMultipleTraders: 'Can I run multiple traders simultaneously?',
|
||||
faqMultipleTradersAnswer:
|
||||
'Yes! NOFX supports running multiple traders with different configurations, AI models, and trading strategies.',
|
||||
|
||||
// Contributing & Community
|
||||
faqGithubProjectsTasks: 'How to use GitHub Projects and pick up tasks?',
|
||||
faqGithubProjectsTasksAnswer:
|
||||
'Roadmap: https://github.com/orgs/NoFxAiOS/projects/3 • Task Dashboard: https://github.com/orgs/NoFxAiOS/projects/5 • Steps: Open links → filter by labels (good first issue / help wanted / frontend / backend) → read Description & Acceptance Criteria → comment "assign me" or self-assign → Fork the repo → sync your fork\'s dev with upstream/dev → create a feature branch from your fork\'s dev → push to your fork → open PR (base: NoFxAiOS/nofx:dev ← compare: your-username/nofx:feat/your-topic) → reference Issue (Closes #123) and use the proper template.',
|
||||
|
||||
faqContributePR: 'How to properly submit PRs and contribute?',
|
||||
faqContributePRAnswer:
|
||||
"Guidelines: • Fork first; branch from your fork's dev (avoid direct commits to upstream main) • Branch naming: feat/..., fix/..., docs/...; Conventional Commits • Run checks before PR: npm --prefix web run lint && npm --prefix web run build • For UI changes, attach screenshots or a short video • Choose the proper PR template (frontend/backend/docs/general) • Open PR from your fork to NoFxAiOS/nofx:dev and link Issue (Closes #123) • Keep rebasing onto upstream/dev; ensure CI passes; prefer small, focused PRs • Read CONTRIBUTING.md and .github/PR_TITLE_GUIDE.md",
|
||||
|
||||
// Setup & Configuration
|
||||
faqSystemRequirements: 'What are the system requirements?',
|
||||
faqSystemRequirementsAnswer:
|
||||
'OS: Linux, macOS, or Windows (Docker recommended); RAM: 2GB minimum, 4GB recommended; Disk: 1GB for application + logs; Network: Stable internet connection.',
|
||||
|
||||
faqNeedCoding: 'Do I need coding experience?',
|
||||
faqNeedCodingAnswer:
|
||||
'No! NOFX has a web UI for all configuration. However, basic command line knowledge helps with setup and troubleshooting.',
|
||||
|
||||
faqGetApiKeys: 'How do I get API keys?',
|
||||
faqGetApiKeysAnswer:
|
||||
'For Binance: Account → API Management → Create API → Enable Futures. For Hyperliquid: Visit Hyperliquid App → API Settings. For Aster DEX: Configure main wallet address (User), API wallet address (Signer), and private key (Private Key).',
|
||||
|
||||
faqUseSubaccount: 'Should I use a subaccount?',
|
||||
faqUseSubaccountAnswer:
|
||||
'Recommended: Yes, use a subaccount dedicated to NOFX for better risk isolation. However, note that some subaccounts have restrictions (e.g., 5x max leverage on Binance).',
|
||||
|
||||
faqDockerDeployment: 'Docker deployment keeps failing',
|
||||
faqDockerDeploymentAnswer:
|
||||
'Common issues: Network connection problems, dependency installation failures, insufficient memory (needs at least 2C2G). If stuck at "go build", try: docker compose down && docker compose build --no-cache && docker compose up -d',
|
||||
|
||||
faqBalanceZero: 'Account balance shows 0',
|
||||
faqBalanceZeroAnswer:
|
||||
'Funds are likely in spot account instead of futures account, or locked in savings products. You need to manually transfer funds to futures account in Binance.',
|
||||
|
||||
faqTestnet: 'Can I use testnet for testing?',
|
||||
faqTestnetAnswer:
|
||||
'Testnet is not supported at the moment. We recommend using real trading with small amounts (10-50 USDT) for testing.',
|
||||
|
||||
// Trading Questions
|
||||
faqNoTrades: "Why isn't my trader making any trades?",
|
||||
faqNoTradesAnswer:
|
||||
'Common reasons: AI decided to "wait" due to market conditions; Insufficient balance or margin; Position limits reached (default: max 3 positions); Check troubleshooting guide for detailed diagnostics.',
|
||||
|
||||
faqDecisionFrequency: 'How often does the AI make decisions?',
|
||||
faqDecisionFrequencyAnswer:
|
||||
'Configurable! Default is every 3-5 minutes. Too frequent = overtrading, too slow = missed opportunities.',
|
||||
|
||||
faqCustomStrategy: 'Can I customize the trading strategy?',
|
||||
faqCustomStrategyAnswer:
|
||||
'Yes! You can adjust leverage settings, modify coin selection pool, change decision intervals, and customize system prompts (advanced).',
|
||||
|
||||
faqMaxPositions: "What's the maximum number of concurrent positions?",
|
||||
faqMaxPositionsAnswer:
|
||||
'Default: 3 positions. This is a soft limit defined in the AI prompt, not hard-coded.',
|
||||
|
||||
faqMarginInsufficient: 'Margin is insufficient error (code=-2019)',
|
||||
faqMarginInsufficientAnswer:
|
||||
'Common causes: Funds not transferred to futures account; Leverage set too high (default 20-50x); Existing positions using margin; Need to transfer USDT from spot to futures account first.',
|
||||
|
||||
faqHighFees: 'Trading fees are too high',
|
||||
faqHighFeesAnswer:
|
||||
'NOFX default 3-minute scan interval can cause frequent trading. Solutions: Increase decision interval to 5-10 minutes; Optimize system prompt to reduce overtrading; Adjust leverage to reduce position sizes.',
|
||||
|
||||
faqNoTakeProfit: "AI doesn't close profitable positions",
|
||||
faqNoTakeProfitAnswer:
|
||||
'AI may believe the trend will continue. The system lacks trailing stop-loss feature currently. You can manually close positions or adjust the system prompt to be more conservative with profit-taking.',
|
||||
|
||||
// Technical Issues
|
||||
faqBinanceApiFailed: 'Binance API call failed (code=-2015)',
|
||||
faqBinanceApiFailedAnswer:
|
||||
'Error: "Invalid API-key, IP, or permissions for action". Solutions: Add server IP to Binance API whitelist; Check API permissions (needs Read + Futures Trading); Ensure using futures API not unified account API; VPN IP might be unstable.',
|
||||
|
||||
faqBinancePositionMode: 'Binance Position Mode Error (code=-4061)',
|
||||
faqBinancePositionModeAnswer:
|
||||
'Error: "Order\'s position side does not match user\'s setting". Solution: Switch to Hedge Mode (双向持仓) in Binance Futures settings. You must close all positions first before switching.',
|
||||
|
||||
faqPortInUse: "Backend won't start / Port already in use",
|
||||
faqPortInUseAnswer:
|
||||
'Check what\'s using port 8080 with "lsof -i :8080" and change the port in your .env file with NOFX_BACKEND_PORT=8081.',
|
||||
|
||||
faqFrontendLoading: 'Frontend shows "Loading..." forever',
|
||||
faqFrontendLoadingAnswer:
|
||||
'Check if backend is running with "curl http://localhost:8080/api/health". Should return {"status":"ok"}. If not, check the troubleshooting guide.',
|
||||
|
||||
faqDatabaseLocked: 'Database locked error',
|
||||
faqDatabaseLockedAnswer:
|
||||
'Stop all NOFX processes with "docker compose down" or "pkill nofx", then restart with "docker compose up -d".',
|
||||
|
||||
faqAiLearningFailed: 'AI learning data failed to load',
|
||||
faqAiLearningFailedAnswer:
|
||||
'Causes: TA-Lib library not properly installed; Insufficient historical data (need completed trades); Environment configuration issues. Install TA-Lib: pip install TA-Lib or check system dependencies.',
|
||||
|
||||
faqConfigNotEffective: 'Configuration changes not taking effect',
|
||||
faqConfigNotEffectiveAnswer:
|
||||
'For Docker: Need to rebuild with "docker compose down && docker compose up -d --build". For PM2: Restart with "pm2 restart all". Check configuration file format and path are correct.',
|
||||
|
||||
// AI & Model Questions
|
||||
faqWhichModels: 'Which AI models are supported?',
|
||||
faqWhichModelsAnswer:
|
||||
'DeepSeek (recommended for cost/performance), Qwen (Alibaba Cloud), and Custom OpenAI-compatible APIs (can be used for OpenAI, Claude via proxy, or other providers).',
|
||||
|
||||
faqApiCosts: 'How much do API calls cost?',
|
||||
faqApiCostsAnswer:
|
||||
'Depends on your model and decision frequency: DeepSeek: ~$0.10-0.50 per day (1 trader, 5min intervals); Qwen: ~$0.20-0.80 per day; Custom API (e.g., OpenAI GPT-4): ~$2-5 per day. Estimates based on typical usage.',
|
||||
|
||||
faqMultipleModels: 'Can I use multiple AI models?',
|
||||
faqMultipleModelsAnswer:
|
||||
'Yes! Each trader can use a different AI model. You can even A/B test different models.',
|
||||
|
||||
faqAiLearning: 'Does the AI learn from its mistakes?',
|
||||
faqAiLearningAnswer:
|
||||
'Yes, to some extent. NOFX provides historical performance feedback in each decision prompt, allowing the AI to adjust its strategy.',
|
||||
|
||||
faqOnlyShort: 'AI only opens short positions, no long positions',
|
||||
faqOnlyShortAnswer:
|
||||
'The default system prompt contains "Don\'t have a long bias! Shorting is one of your core tools" which may cause this. Also affected by 4-hour timeframe data and model training bias. You can modify the system prompt to be more balanced.',
|
||||
|
||||
faqModelSelection: 'Which DeepSeek version should I use?',
|
||||
faqModelSelectionAnswer:
|
||||
"DeepSeek V3 is recommended for best performance. Alternatives: DeepSeek R1 (reasoning model, slower but better logic), SiliconFlow's DeepSeek (alternative API provider). Most users report good results with V3.",
|
||||
|
||||
// Data & Privacy
|
||||
faqDataStorage: 'Where is my data stored?',
|
||||
faqDataStorageAnswer:
|
||||
'All data is stored locally on your machine in SQLite databases: config.db (trader configurations), trading.db (trade history), and decision_logs/ (AI decision records).',
|
||||
|
||||
faqApiKeySecurity: 'Is my API key secure?',
|
||||
faqApiKeySecurityAnswer:
|
||||
'API keys are stored in local databases. Never share your databases or .env files. We recommend using API keys with IP whitelist restrictions.',
|
||||
|
||||
faqExportHistory: 'Can I export my trading history?',
|
||||
faqExportHistoryAnswer:
|
||||
'Yes! Trading data is in SQLite format. You can query it directly with: sqlite3 trading.db "SELECT * FROM trades;"',
|
||||
|
||||
faqGetHelp: 'Where can I get help?',
|
||||
faqGetHelpAnswer:
|
||||
'Check GitHub Discussions, join our Telegram Community, or open an issue on GitHub.',
|
||||
|
||||
// Web Crypto Environment Check
|
||||
environmentCheck: {
|
||||
button: 'Check Secure Environment',
|
||||
checking: 'Checking...',
|
||||
description:
|
||||
'Automatically verifying whether this browser context allows Web Crypto before entering sensitive keys.',
|
||||
secureTitle: 'Secure context detected',
|
||||
secureDesc:
|
||||
'Web Crypto API is available. You can continue entering secrets with encryption enabled.',
|
||||
insecureTitle: 'Insecure context detected',
|
||||
insecureDesc:
|
||||
'This page is not running over HTTPS or a trusted localhost origin, so browsers block Web Crypto calls.',
|
||||
tipsTitle: 'How to fix:',
|
||||
tipHTTPS:
|
||||
'Serve the dashboard over HTTPS with a valid certificate (IP origins also need TLS).',
|
||||
tipLocalhost:
|
||||
'During development, open the app via http://localhost or 127.0.0.1.',
|
||||
tipIframe:
|
||||
'Avoid embedding the app in insecure HTTP iframes or reverse proxies that strip HTTPS.',
|
||||
unsupportedTitle: 'Browser does not expose Web Crypto',
|
||||
unsupportedDesc:
|
||||
'Open NOFX over HTTPS (or http://localhost during development) and avoid insecure iframes/reverse proxies so the browser can enable Web Crypto.',
|
||||
summary: 'Current origin: {origin} • Protocol: {protocol}',
|
||||
},
|
||||
|
||||
environmentSteps: {
|
||||
checkTitle: '1. Environment check',
|
||||
selectTitle: '2. Select exchange',
|
||||
},
|
||||
|
||||
// Two-Stage Key Modal
|
||||
twoStageKey: {
|
||||
title: 'Two-Stage Private Key Input',
|
||||
stage1Description:
|
||||
'Enter the first {length} characters of your private key',
|
||||
stage2Description:
|
||||
'Enter the remaining {length} characters of your private key',
|
||||
stage1InputLabel: 'First Part',
|
||||
stage2InputLabel: 'Second Part',
|
||||
characters: 'characters',
|
||||
processing: 'Processing...',
|
||||
nextButton: 'Next',
|
||||
cancelButton: 'Cancel',
|
||||
backButton: 'Back',
|
||||
encryptButton: 'Encrypt & Submit',
|
||||
obfuscationCopied: 'Obfuscation data copied to clipboard',
|
||||
obfuscationInstruction:
|
||||
'Paste something else to clear clipboard, then continue',
|
||||
obfuscationManual: 'Manual obfuscation required',
|
||||
},
|
||||
|
||||
// Error Messages
|
||||
errors: {
|
||||
privatekeyIncomplete: 'Please enter at least {expected} characters',
|
||||
privatekeyInvalidFormat:
|
||||
'Invalid private key format (should be 64 hex characters)',
|
||||
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
@@ -513,6 +864,7 @@ export const translations = {
|
||||
realtimeNav: '实时',
|
||||
configNav: '配置',
|
||||
dashboardNav: '看板',
|
||||
faqNav: '常见问题',
|
||||
|
||||
// Footer
|
||||
footerTitle: 'NOFX - AI交易系统',
|
||||
@@ -638,6 +990,10 @@ export const translations = {
|
||||
currentTraders: '当前交易员',
|
||||
noTraders: '暂无AI交易员',
|
||||
createFirstTrader: '创建您的第一个AI交易员开始使用',
|
||||
dashboardEmptyTitle: '开始使用吧!',
|
||||
dashboardEmptyDescription:
|
||||
'创建您的第一个 AI 交易员,自动化您的交易策略。连接交易所、选择 AI 模型,几分钟内即可开始交易!',
|
||||
goToTradersPage: '创建您的第一个交易员',
|
||||
configureModelsFirst: '请先配置AI模型',
|
||||
configureExchangesFirst: '请先配置交易所',
|
||||
configureModelsAndExchangesFirst: '请先配置AI模型和交易所',
|
||||
@@ -687,6 +1043,18 @@ export const translations = {
|
||||
enterPassphrase: '输入Passphrase (OKX必填)',
|
||||
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
|
||||
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
|
||||
// Hyperliquid 代理钱包 (新安全模型)
|
||||
hyperliquidAgentWalletTitle: 'Hyperliquid 代理钱包配置',
|
||||
hyperliquidAgentWalletDesc:
|
||||
'使用代理钱包安全交易:代理钱包用于签名(餘額~0),主钱包持有资金(永不暴露私钥)',
|
||||
hyperliquidAgentPrivateKey: '代理私钥',
|
||||
enterHyperliquidAgentPrivateKey: '输入代理钱包私钥',
|
||||
hyperliquidAgentPrivateKeyDesc:
|
||||
'代理钱包私钥,用于签名交易(为了安全应保持余额接近0)',
|
||||
hyperliquidMainWalletAddress: '主钱包地址',
|
||||
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
||||
hyperliquidMainWalletAddressDesc:
|
||||
'持有交易资金的主钱包地址(永不暴露其私钥)',
|
||||
asterUserDesc:
|
||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(注意:仅支持 EVM 钱包,不支持 Solana 钱包)',
|
||||
asterSignerDesc:
|
||||
@@ -695,6 +1063,41 @@ export const translations = {
|
||||
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||
asterUsdtWarning:
|
||||
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
||||
|
||||
// Exchange names
|
||||
hyperliquidExchangeName: 'Hyperliquid',
|
||||
asterExchangeName: 'Aster DEX',
|
||||
|
||||
// Secure input
|
||||
secureInputButton: '安全输入',
|
||||
secureInputReenter: '重新安全输入',
|
||||
secureInputClear: '清除',
|
||||
secureInputHint:
|
||||
'已通过安全双阶段输入设置。若需修改,请点击"重新安全输入"。',
|
||||
|
||||
// Two Stage Key Modal
|
||||
twoStageModalTitle: '安全私钥输入',
|
||||
twoStageModalDescription: '使用双阶段流程安全输入长度为 {length} 的私钥。',
|
||||
twoStageStage1Title: '步骤一 · 输入前半段',
|
||||
twoStageStage1Placeholder: '前 32 位字符(若有 0x 前缀请保留)',
|
||||
twoStageStage1Hint:
|
||||
'继续后会将扰动字符串复制到剪贴板,用于迷惑剪贴板监控。',
|
||||
twoStageStage1Error: '请先输入第一段私钥。',
|
||||
twoStageNext: '下一步',
|
||||
twoStageProcessing: '处理中…',
|
||||
twoStageCancel: '取消',
|
||||
twoStageStage2Title: '步骤二 · 输入剩余部分',
|
||||
twoStageStage2Placeholder: '剩余的私钥字符',
|
||||
twoStageStage2Hint: '将扰动字符串粘贴到任意位置后,再完成私钥输入。',
|
||||
twoStageClipboardSuccess:
|
||||
'扰动字符串已复制。请在完成前在任意文本处粘贴一次以迷惑剪贴板记录。',
|
||||
twoStageClipboardReminder:
|
||||
'记得在提交前粘贴一次扰动字符串,降低剪贴板泄漏风险。',
|
||||
twoStageClipboardManual: '自动复制失败,请手动复制下面的扰动字符串。',
|
||||
twoStageBack: '返回',
|
||||
twoStageSubmit: '确认',
|
||||
twoStageInvalidFormat:
|
||||
'私钥格式不正确,应为 {length} 位十六进制字符(可选 0x 前缀)。',
|
||||
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
|
||||
securityWarning: '安全提示',
|
||||
saveConfiguration: '保存配置',
|
||||
@@ -723,6 +1126,32 @@ export const translations = {
|
||||
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
|
||||
invalidSymbolFormat: '无效的币种格式:{symbol},必须以USDT结尾',
|
||||
|
||||
// System Prompt Templates
|
||||
systemPromptTemplate: '系统提示词模板',
|
||||
promptTemplateDefault: '默认稳健',
|
||||
promptTemplateAdaptive: '保守策略',
|
||||
promptTemplateAdaptiveRelaxed: '激进策略',
|
||||
promptTemplateHansen: 'Hansen 策略',
|
||||
promptTemplateNof1: 'NoF1 英文框架',
|
||||
promptTemplateTaroLong: 'Taro 长仓',
|
||||
promptDescDefault: '📊 默认稳健策略',
|
||||
promptDescDefaultContent:
|
||||
'最大化夏普比率,平衡风险收益,适合新手和长期稳定交易',
|
||||
promptDescAdaptive: '🛡️ 保守策略 (v6.0.0)',
|
||||
promptDescAdaptiveContent:
|
||||
'严格风控,BTC 强制确认,高胜率优先,适合保守型交易者',
|
||||
promptDescAdaptiveRelaxed: '⚡ 激进策略 (v6.0.0)',
|
||||
promptDescAdaptiveRelaxedContent:
|
||||
'高频交易,BTC 可选确认,追求交易机会,适合波动市场',
|
||||
promptDescHansen: '🎯 Hansen 策略',
|
||||
promptDescHansenContent: 'Hansen 定制策略,最大化夏普比率,专业交易者专用',
|
||||
promptDescNof1: '🌐 NoF1 英文框架',
|
||||
promptDescNof1Content:
|
||||
'Hyperliquid 交易所专用,英文提示词,风险调整回报最大化',
|
||||
promptDescTaroLong: '📈 Taro 长仓策略',
|
||||
promptDescTaroLongContent:
|
||||
'数据驱动决策,多维度验证,持续学习进化,长仓专用',
|
||||
|
||||
// Loading & Error
|
||||
loading: '加载中...',
|
||||
loadingError: '⚠️ 加载AI学习数据失败',
|
||||
@@ -744,6 +1173,9 @@ export const translations = {
|
||||
editAIModel: '编辑AI模型',
|
||||
addAIModel: '添加AI模型',
|
||||
confirmDeleteModel: '确定要删除此AI模型配置吗?',
|
||||
cannotDeleteModelInUse: '无法删除此AI模型,因为有交易员正在使用',
|
||||
tradersUsing: '正在使用此配置的交易员',
|
||||
pleaseDeleteTradersFirst: '请先删除或重新配置这些交易员',
|
||||
selectModel: '选择AI模型',
|
||||
pleaseSelectModel: '请选择模型',
|
||||
customBaseURL: 'Base URL (可选)',
|
||||
@@ -756,6 +1188,7 @@ export const translations = {
|
||||
editExchange: '编辑交易所',
|
||||
addExchange: '添加交易所',
|
||||
confirmDeleteExchange: '确定要删除此交易所配置吗?',
|
||||
cannotDeleteExchangeInUse: '无法删除此交易所,因为有交易员正在使用',
|
||||
pleaseSelectExchange: '请选择交易所',
|
||||
exchangeConfigWarning1: '• API密钥将被加密存储,建议使用只读或期货交易权限',
|
||||
exchangeConfigWarning2: '• 不要授予提现权限,确保资金安全',
|
||||
@@ -769,6 +1202,7 @@ export const translations = {
|
||||
serverIPAddresses: '服务器IP地址',
|
||||
copyIP: '复制',
|
||||
ipCopied: 'IP已复制',
|
||||
copyIPFailed: 'IP地址复制失败,请手动复制',
|
||||
loadingServerIP: '正在加载服务器IP...',
|
||||
|
||||
// Error Messages
|
||||
@@ -785,16 +1219,27 @@ export const translations = {
|
||||
exchangeNotExist: '交易所不存在',
|
||||
deleteExchangeConfigFailed: '删除交易所配置失败',
|
||||
saveSignalSourceFailed: '保存信号源配置失败',
|
||||
encryptionFailed: '加密敏感数据失败',
|
||||
|
||||
// Login & Register
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
emailPlaceholder: '请输入邮箱地址',
|
||||
passwordPlaceholder: '请输入密码(至少6位)',
|
||||
confirmPasswordPlaceholder: '请再次输入密码',
|
||||
passwordRequirements: '密码要求',
|
||||
passwordRuleMinLength: '至少 8 位',
|
||||
passwordRuleUppercase: '至少 1 个大写字母',
|
||||
passwordRuleLowercase: '至少 1 个小写字母',
|
||||
passwordRuleNumber: '至少 1 个数字',
|
||||
passwordRuleSpecial: '至少 1 个特殊字符(@#$%!&*?)',
|
||||
passwordRuleMatch: '两次密码一致',
|
||||
passwordNotMeetRequirements: '密码不符合安全要求',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: '登录到您的账户',
|
||||
registerTitle: '创建新账户',
|
||||
@@ -807,6 +1252,14 @@ export const translations = {
|
||||
loginNow: '立即登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
rememberMe: '记住我',
|
||||
resetPassword: '重置密码',
|
||||
resetPasswordTitle: '重置您的密码',
|
||||
newPassword: '新密码',
|
||||
newPasswordPlaceholder: '请输入新密码(至少6位)',
|
||||
resetPasswordButton: '重置密码',
|
||||
resetPasswordSuccess: '密码重置成功!请使用新密码登录',
|
||||
resetPasswordFailed: '密码重置失败',
|
||||
backToLogin: '返回登录',
|
||||
otpCode: 'OTP验证码',
|
||||
scanQRCode: '扫描二维码',
|
||||
enterOTPCode: '输入6位OTP验证码',
|
||||
@@ -828,10 +1281,15 @@ export const translations = {
|
||||
completeRegistrationSubtitle: '以完成注册',
|
||||
loginSuccess: '登录成功',
|
||||
registrationSuccess: '注册成功',
|
||||
loginFailed: '登录失败',
|
||||
registrationFailed: '注册失败',
|
||||
verificationFailed: 'OTP验证失败',
|
||||
loginFailed: '登录失败,请检查您的邮箱和密码。',
|
||||
registrationFailed: '注册失败,请重试。',
|
||||
verificationFailed: 'OTP 验证失败,请检查验证码后重试。',
|
||||
invalidCredentials: '邮箱或密码错误',
|
||||
weak: '弱',
|
||||
medium: '中',
|
||||
strong: '强',
|
||||
passwordStrength: '密码强度',
|
||||
passwordStrengthHint: '建议至少8位,包含大小写、数字和符号',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
emailRequired: '请输入邮箱',
|
||||
passwordRequired: '请输入密码',
|
||||
@@ -948,8 +1406,231 @@ export const translations = {
|
||||
orConfigureCorrectApiUrl: '或者配置正确的币种池API地址',
|
||||
orDisableCoinPoolOptions: '或者禁用"使用币种池"和"使用OI Top"选项',
|
||||
signalSourceNotConfigured: '信号源未配置',
|
||||
signalSourceWarningMessage: '您有交易员启用了"使用币种池"或"使用OI Top",但尚未配置信号源API地址。这将导致候选币种数量为0,交易员无法正常工作。',
|
||||
signalSourceWarningMessage:
|
||||
'您有交易员启用了"使用币种池"或"使用OI Top",但尚未配置信号源API地址。这将导致候选币种数量为0,交易员无法正常工作。',
|
||||
configureSignalSourceNow: '立即配置信号源',
|
||||
|
||||
// FAQ Page
|
||||
faqTitle: '常见问题',
|
||||
faqSubtitle: '查找关于 NOFX 的常见问题解答',
|
||||
faqStillHaveQuestions: '还有其他问题?',
|
||||
faqContactUs: '加入我们的社区或查看 GitHub 获取更多帮助',
|
||||
|
||||
// FAQ Categories
|
||||
faqCategoryBasics: '基础问题',
|
||||
faqCategoryContributing: '贡献与任务',
|
||||
faqCategorySetup: '安装与配置',
|
||||
faqCategoryTrading: '交易问题',
|
||||
faqCategoryTechnical: '技术问题',
|
||||
faqCategoryAI: 'AI与模型问题',
|
||||
faqCategoryData: '数据与隐私',
|
||||
|
||||
// FAQ Questions & Answers - General
|
||||
faqWhatIsNOFX: 'NOFX 是什么?',
|
||||
faqWhatIsNOFXAnswer:
|
||||
'NOFX 是一个 AI 驱动的加密货币交易机器人,使用大语言模型(LLM)在期货市场进行交易决策。',
|
||||
|
||||
faqSupportedExchanges: '支持哪些交易所?',
|
||||
faqSupportedExchangesAnswer:
|
||||
'支持币安合约(Binance Futures)、Hyperliquid 和 Aster DEX。更多交易所开发中。',
|
||||
|
||||
faqIsProfitable: 'NOFX 能盈利吗?',
|
||||
faqIsProfitableAnswer:
|
||||
'AI 交易是实验性的,不保证盈利。请始终用小额资金测试,不要投入超过您承受能力的资金。',
|
||||
|
||||
faqMultipleTraders: '可以同时运行多个交易员吗?',
|
||||
faqMultipleTradersAnswer:
|
||||
'可以!NOFX 支持运行多个交易员,每个可配置不同的 AI 模型和交易策略。',
|
||||
|
||||
// Contributing & Community
|
||||
faqGithubProjectsTasks: '如何在 GitHub Projects 中领取任务?',
|
||||
faqGithubProjectsTasksAnswer:
|
||||
'路线图:https://github.com/orgs/NoFxAiOS/projects/3 | 任务看板:https://github.com/orgs/NoFxAiOS/projects/5 | 步骤:打开链接 → 按标签筛选(good first issue / help wanted / frontend / backend)→ 阅读描述与验收标准 → 评论“assign me”或自助分配 → Fork 仓库 → 同步你 fork 的 dev 与 upstream/dev → 从你 fork 的 dev 创建特性分支 → 推送到你的 fork → 打开 PR(base:NoFxAiOS/nofx:dev ← compare:你的用户名/nofx:feat/your-topic)→ 关联 Issue(Closes #123)并选择正确模板。',
|
||||
|
||||
faqContributePR: '如何规范地提交 PR 并参与贡献?',
|
||||
faqContributePRAnswer:
|
||||
'规范:• 先 Fork;在你的 fork 的 dev 分支上创建特性分支(避免直接向上游 main 提交)• 分支命名:feat/...、fix/...、docs/...;提交信息遵循 Conventional Commits • PR 前运行检查:npm --prefix web run lint && npm --prefix web run build • 涉及 UI 变更请附截图/短视频 • 选择正确 PR 模板(frontend/backend/docs/general)• 从你的 fork 发起到 NoFxAiOS/nofx:dev,并在 PR 中关联 Issue(Closes #123)• 持续 rebase 到 upstream/dev,确保 CI 通过;尽量保持 PR 小而聚焦 • 参考 CONTRIBUTING.md 与 .github/PR_TITLE_GUIDE.md',
|
||||
|
||||
// Setup & Configuration
|
||||
faqSystemRequirements: '系统要求是什么?',
|
||||
faqSystemRequirementsAnswer:
|
||||
'操作系统:Linux、macOS 或 Windows(推荐 Docker);内存:最低 2GB,推荐 4GB;硬盘:应用 + 日志需要 1GB;网络:稳定的互联网连接。',
|
||||
|
||||
faqNeedCoding: '需要编程经验吗?',
|
||||
faqNeedCodingAnswer:
|
||||
'不需要!NOFX 有 Web 界面进行所有配置。但基础的命令行知识有助于安装和故障排查。',
|
||||
|
||||
faqGetApiKeys: '如何获取 API 密钥?',
|
||||
faqGetApiKeysAnswer:
|
||||
'币安:账户 → API 管理 → 创建 API → 启用合约。Hyperliquid:访问 Hyperliquid App → API 设置。Aster DEX:配置主钱包地址(User)、API 钱包地址(Signer)和私钥(Private Key)。',
|
||||
|
||||
faqUseSubaccount: '应该使用子账户吗?',
|
||||
faqUseSubaccountAnswer:
|
||||
'推荐:是的,使用专门的子账户运行 NOFX 可以更好地隔离风险。但请注意,某些子账户有限制(例如币安子账户最高 5 倍杠杆)。',
|
||||
|
||||
faqDockerDeployment: 'Docker 部署一直失败',
|
||||
faqDockerDeploymentAnswer:
|
||||
'常见问题:网络连接问题、依赖安装失败、内存不足(需要至少 2C2G)。如果卡在 "go build" 不动,尝试:docker compose down && docker compose build --no-cache && docker compose up -d',
|
||||
|
||||
faqBalanceZero: '账户余额显示为 0',
|
||||
faqBalanceZeroAnswer:
|
||||
'资金可能在现货账户而非合约账户,或被理财功能锁定。您需要在币安手动将资金划转到合约账户。',
|
||||
|
||||
faqTestnet: '可以使用测试网测试吗?',
|
||||
faqTestnetAnswer:
|
||||
'暂时不支持测试网。我们建议使用真实交易但小额资金(10-50 USDT)进行测试。',
|
||||
|
||||
// Trading Questions
|
||||
faqNoTrades: '为什么我的交易员不开仓?',
|
||||
faqNoTradesAnswer:
|
||||
'常见原因:AI 根据市场情况决定"等待";余额或保证金不足;达到持仓上限(默认最多 3 个仓位);查看故障排查指南了解详细诊断。',
|
||||
|
||||
faqDecisionFrequency: 'AI 多久做一次决策?',
|
||||
faqDecisionFrequencyAnswer:
|
||||
'可配置!默认是每 3-5 分钟。太频繁 = 过度交易,太慢 = 错过机会。',
|
||||
|
||||
faqCustomStrategy: '可以自定义交易策略吗?',
|
||||
faqCustomStrategyAnswer:
|
||||
'可以!您可以调整杠杆设置、修改币种选择池、更改决策间隔、自定义系统提示词(高级)。',
|
||||
|
||||
faqMaxPositions: '最多可以同时持有多少个仓位?',
|
||||
faqMaxPositionsAnswer:
|
||||
'默认:3 个仓位。这是 AI 提示词中的软限制,不是硬编码。',
|
||||
|
||||
faqMarginInsufficient: '保证金不足错误 (code=-2019)',
|
||||
faqMarginInsufficientAnswer:
|
||||
'常见原因:资金未划转到合约账户;杠杆倍数设置过高(默认 20-50 倍);已有持仓占用保证金;需要先从现货账户划转 USDT 到合约账户。',
|
||||
|
||||
faqHighFees: '交易手续费太高',
|
||||
faqHighFeesAnswer:
|
||||
'NOFX 默认 3 分钟扫描间隔会导致频繁交易。解决方案:将决策间隔增加到 5-10 分钟;优化系统提示词减少过度交易;调整杠杆降低仓位大小。',
|
||||
|
||||
faqNoTakeProfit: 'AI 不平掉盈利的仓位',
|
||||
faqNoTakeProfitAnswer:
|
||||
'AI 可能认为趋势会继续。系统目前缺少移动止盈功能。您可以手动平仓或调整系统提示词使其在获利时更保守。',
|
||||
|
||||
// Technical Issues
|
||||
faqBinanceApiFailed: '币安 API 调用失败 (code=-2015)',
|
||||
faqBinanceApiFailedAnswer:
|
||||
'错误:"Invalid API-key, IP, or permissions for action"。解决方案:将服务器 IP 添加到币安 API 白名单;检查 API 权限(需要读取 + 合约交易);确保使用合约 API 而非统一账户 API;VPN IP 可能不稳定。',
|
||||
|
||||
faqBinancePositionMode: '币安持仓模式错误 (code=-4061)',
|
||||
faqBinancePositionModeAnswer:
|
||||
'错误信息:"Order\'s position side does not match user\'s setting"。解决方法:切换为双向持仓模式。登录币安合约 → 点击右上角偏好设置 → 选择持仓模式 → 双向持仓。注意:先平掉所有持仓。',
|
||||
|
||||
faqPortInUse: '后端无法启动 / 端口被占用',
|
||||
faqPortInUseAnswer:
|
||||
'使用 "lsof -i :8080" 查看占用端口的进程,在 .env 中修改端口:NOFX_BACKEND_PORT=8081。',
|
||||
|
||||
faqFrontendLoading: '前端一直显示"加载中..."',
|
||||
faqFrontendLoadingAnswer:
|
||||
'使用 "curl http://localhost:8080/api/health" 检查后端是否运行。应该返回 {"status":"ok"}。如果不是,查看故障排查指南。',
|
||||
|
||||
faqDatabaseLocked: '数据库锁定错误',
|
||||
faqDatabaseLockedAnswer:
|
||||
'使用 "docker compose down" 或 "pkill nofx" 停止所有 NOFX 进程,然后使用 "docker compose up -d" 重启。',
|
||||
|
||||
faqAiLearningFailed: 'AI 学习数据加载失败',
|
||||
faqAiLearningFailedAnswer:
|
||||
'原因:TA-Lib 库未正确安装;历史数据不足(需要完成交易);环境配置问题。安装 TA-Lib:pip install TA-Lib 或检查系统依赖。',
|
||||
|
||||
faqConfigNotEffective: '配置文件修改不生效',
|
||||
faqConfigNotEffectiveAnswer:
|
||||
'Docker 需要重新构建:"docker compose down && docker compose up -d --build"。PM2 需要重启:"pm2 restart all"。检查配置文件格式和路径是否正确。',
|
||||
|
||||
// AI & Model Questions
|
||||
faqWhichModels: '支持哪些 AI 模型?',
|
||||
faqWhichModelsAnswer:
|
||||
'DeepSeek(推荐性价比)、Qwen(阿里云通义千问)、自定义 OpenAI 兼容 API(可用于 OpenAI、通过代理的 Claude 或其他提供商)。',
|
||||
|
||||
faqApiCosts: 'API 调用成本是多少?',
|
||||
faqApiCostsAnswer:
|
||||
'取决于您的模型和决策频率:DeepSeek:每天约 $0.10-0.50(1 个交易员,5 分钟间隔);Qwen:每天约 $0.20-0.80;自定义 API(例如 OpenAI GPT-4):每天约 $2-5。基于典型使用的估算。',
|
||||
|
||||
faqMultipleModels: '可以使用多个 AI 模型吗?',
|
||||
faqMultipleModelsAnswer:
|
||||
'可以!每个交易员可以使用不同的 AI 模型。您甚至可以 A/B 测试不同模型。',
|
||||
|
||||
faqAiLearning: 'AI 会从错误中学习吗?',
|
||||
faqAiLearningAnswer:
|
||||
'会的,在一定程度上。NOFX 在每次决策提示中提供历史表现反馈,允许 AI 调整策略。',
|
||||
|
||||
faqOnlyShort: 'AI 只开空单,不开多单',
|
||||
faqOnlyShortAnswer:
|
||||
'默认系统提示词包含"不要有做多偏见!做空是你的核心工具之一",可能导致此问题。还受 4 小时周期数据和模型训练偏向性影响。您可以修改系统提示词使其更平衡。',
|
||||
|
||||
faqModelSelection: '应该使用哪个 DeepSeek 版本?',
|
||||
faqModelSelectionAnswer:
|
||||
'推荐使用 DeepSeek V3 以获得最佳性能。备选:DeepSeek R1(推理模型,较慢但逻辑更好)、SiliconFlow 的 DeepSeek(备用 API 提供商)。大多数用户反馈 V3 效果良好。',
|
||||
|
||||
// Data & Privacy
|
||||
faqDataStorage: '我的数据存储在哪里?',
|
||||
faqDataStorageAnswer:
|
||||
'所有数据都本地存储在您的机器上,使用 SQLite 数据库:config.db(交易员配置)、trading.db(交易历史)、decision_logs/(AI 决策记录)。',
|
||||
|
||||
faqApiKeySecurity: 'API 密钥安全吗?',
|
||||
faqApiKeySecurityAnswer:
|
||||
'API 密钥存储在本地数据库中。永远不要分享您的数据库或 .env 文件。我们建议使用带 IP 白名单限制的 API 密钥。',
|
||||
|
||||
faqExportHistory: '可以导出交易历史吗?',
|
||||
faqExportHistoryAnswer:
|
||||
'可以!交易数据是 SQLite 格式。您可以直接查询:sqlite3 trading.db "SELECT * FROM trades;"',
|
||||
|
||||
faqGetHelp: '在哪里可以获得帮助?',
|
||||
faqGetHelpAnswer:
|
||||
'查看 GitHub Discussions、加入 Telegram 社区或在 GitHub 上提出 issue。',
|
||||
|
||||
// Web Crypto Environment Check
|
||||
environmentCheck: {
|
||||
button: '一键检测环境',
|
||||
checking: '正在检测...',
|
||||
description: '系统将自动检测当前浏览器是否允许使用 Web Crypto。',
|
||||
secureTitle: '环境安全,已启用 Web Crypto',
|
||||
secureDesc: '页面处于安全上下文,可继续输入敏感信息并使用加密传输。',
|
||||
insecureTitle: '检测到非安全环境',
|
||||
insecureDesc:
|
||||
'当前访问未通过 HTTPS 或可信 localhost,浏览器会阻止 Web Crypto 调用。',
|
||||
tipsTitle: '修改建议:',
|
||||
tipHTTPS:
|
||||
'通过 HTTPS 访问(即使是 IP 也需证书),或部署到支持 TLS 的域名。',
|
||||
tipLocalhost: '开发阶段请使用 http://localhost 或 127.0.0.1。',
|
||||
tipIframe:
|
||||
'避免把应用嵌入在不安全的 HTTP iframe 或会降级协议的反向代理中。',
|
||||
unsupportedTitle: '浏览器未提供 Web Crypto',
|
||||
unsupportedDesc:
|
||||
'请通过 HTTPS 或本机 localhost 访问 NOFX,并避免嵌入不安全 iframe/反向代理,以符合浏览器的 Web Crypto 规则。',
|
||||
summary: '当前来源:{origin} · 协议:{protocol}',
|
||||
},
|
||||
|
||||
environmentSteps: {
|
||||
checkTitle: '1. 环境检测',
|
||||
selectTitle: '2. 选择交易所',
|
||||
},
|
||||
|
||||
// Two-Stage Key Modal
|
||||
twoStageKey: {
|
||||
title: '两阶段私钥输入',
|
||||
stage1Description: '请输入私钥的前 {length} 位字符',
|
||||
stage2Description: '请输入私钥的后 {length} 位字符',
|
||||
stage1InputLabel: '第一部分',
|
||||
stage2InputLabel: '第二部分',
|
||||
characters: '位字符',
|
||||
processing: '处理中...',
|
||||
nextButton: '下一步',
|
||||
cancelButton: '取消',
|
||||
backButton: '返回',
|
||||
encryptButton: '加密并提交',
|
||||
obfuscationCopied: '混淆数据已复制到剪贴板',
|
||||
obfuscationInstruction: '请粘贴其他内容清空剪贴板,然后继续',
|
||||
obfuscationManual: '需要手动混淆',
|
||||
},
|
||||
|
||||
// Error Messages
|
||||
errors: {
|
||||
privatekeyIncomplete: '请输入至少 {expected} 位字符',
|
||||
privatekeyInvalidFormat: '私钥格式无效(应为64位十六进制字符)',
|
||||
privatekeyObfuscationFailed: '剪贴板混淆失败',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -958,7 +1639,15 @@ export function t(
|
||||
lang: Language,
|
||||
params?: Record<string, string | number>
|
||||
): string {
|
||||
let text = translations[lang][key as keyof (typeof translations)['en']] || key
|
||||
// Handle nested keys like 'twoStageKey.title'
|
||||
const keys = key.split('.')
|
||||
let value: any = translations[lang]
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
}
|
||||
|
||||
let text = typeof value === 'string' ? value : key
|
||||
|
||||
// Replace parameters like {count}, {gap}, etc.
|
||||
if (params) {
|
||||
|
||||
@@ -200,6 +200,69 @@ body {
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
/* Sonner (toast) - Binance theme overrides */
|
||||
.sonner-toaster {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.nofx-toast {
|
||||
background: #0b0e11 !important;
|
||||
border: 1px solid var(--panel-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.nofx-toast .sonner-title {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nofx-toast .sonner-description {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Success / Error / Warning tint */
|
||||
.nofx-toast[data-type='success'] {
|
||||
background: #0b0e11 !important;
|
||||
border-color: var(--binance-green) !important;
|
||||
border-left: 3px solid var(--binance-green) !important;
|
||||
}
|
||||
.nofx-toast[data-type='success'] .sonner-title,
|
||||
.nofx-toast[data-type='success'] .sonner-description {
|
||||
color: var(--binance-green) !important;
|
||||
}
|
||||
|
||||
.nofx-toast[data-type='error'] {
|
||||
background: #0b0e11 !important;
|
||||
border-color: var(--binance-red) !important;
|
||||
border-left: 3px solid var(--binance-red) !important;
|
||||
}
|
||||
.nofx-toast[data-type='error'] .sonner-title,
|
||||
.nofx-toast[data-type='error'] .sonner-description {
|
||||
color: var(--binance-red) !important;
|
||||
}
|
||||
|
||||
.nofx-toast[data-type='warning'],
|
||||
.nofx-toast[data-type='info'] {
|
||||
background: #0b0e11 !important;
|
||||
border-color: var(--binance-yellow) !important;
|
||||
border-left: 3px solid var(--binance-yellow) !important;
|
||||
}
|
||||
.nofx-toast[data-type='warning'] .sonner-title,
|
||||
.nofx-toast[data-type='warning'] .sonner-description,
|
||||
.nofx-toast[data-type='info'] .sonner-title,
|
||||
.nofx-toast[data-type='info'] .sonner-description {
|
||||
color: var(--binance-yellow) !important;
|
||||
}
|
||||
|
||||
.nofx-toast .sonner-close-button {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.nofx-toast .sonner-close-button:hover {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Monospace numbers */
|
||||
.mono {
|
||||
font-family: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
@@ -235,6 +298,113 @@ button:disabled {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.dev-toast-controller {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: min(320px, 85vw);
|
||||
background: rgba(11, 14, 17, 0.9);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(16px);
|
||||
font-size: 0.85rem;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.dev-toast-controller__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dev-toast-controller__header small {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.dev-toast-controller__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dev-toast-controller__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dev-toast-controller__label select,
|
||||
.dev-toast-controller__label input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dev-toast-controller__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dev-toast-controller__actions button {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-toast-controller__actions button:first-child {
|
||||
background: rgba(240, 185, 11, 0.15);
|
||||
color: var(--binance-yellow);
|
||||
border: 1px solid rgba(240, 185, 11, 0.4);
|
||||
}
|
||||
|
||||
.dev-toast-controller__actions button:last-child {
|
||||
background: rgba(132, 142, 156, 0.15);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.dev-toast-controller__actions button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dev-custom-toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f0b90b, #df8c0c);
|
||||
color: #0a0a0a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-custom-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dev-custom-body {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.binance-card:hover {
|
||||
border-color: var(--panel-border-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
56
web/src/layouts/AuthLayout.tsx
Normal file
56
web/src/layouts/AuthLayout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Outlet, Link } from 'react-router-dom'
|
||||
import { Container } from '../components/Container'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
||||
{/* Simple Header with Logo and Language Selector */}
|
||||
<nav
|
||||
className="fixed top-0 w-full z-50"
|
||||
style={{
|
||||
background: 'rgba(11, 14, 17, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Container className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<span className="text-xl font-bold" style={{ color: '#F0B90B' }}>
|
||||
NOFX
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
|
||||
className="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? 'English' : '中文'}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</nav>
|
||||
|
||||
{/* Content with top padding to avoid overlap with fixed header */}
|
||||
<div className="pt-16">{children || <Outlet />}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
web/src/layouts/MainLayout.tsx
Normal file
97
web/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import HeaderBar from '../components/HeaderBar'
|
||||
import { Container } from '../components/Container'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
// 根据路径自动判断当前页面
|
||||
const getCurrentPage = (): 'competition' | 'traders' | 'trader' | 'faq' => {
|
||||
if (location.pathname === '/faq') return 'faq'
|
||||
if (location.pathname === '/traders') return 'traders'
|
||||
if (location.pathname === '/dashboard') return 'trader'
|
||||
if (location.pathname === '/competition') return 'competition'
|
||||
return 'competition' // 默认
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage={getCurrentPage()}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onPageChange={() => {
|
||||
// React Router handles navigation now
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Container as="main" className="py-6 pt-24">
|
||||
{children || <Outlet />}
|
||||
</Container>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
<Container
|
||||
className="py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
<div className="mt-4">
|
||||
<a
|
||||
href="https://github.com/tinkle-community/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -32,51 +34,51 @@ function getAuthHeaders(): Record<string, string> {
|
||||
export const api = {
|
||||
// AI交易员管理接口
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const res = await fetch(`${API_BASE}/my-traders`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开的交易员列表(无需认证)
|
||||
async getPublicTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/traders`)
|
||||
const res = await httpClient.get(`${API_BASE}/traders`)
|
||||
if (!res.ok) throw new Error('获取公开trader列表失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('创建交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async deleteTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.delete(
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('删除交易员失败')
|
||||
},
|
||||
|
||||
async startTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/start`,
|
||||
undefined,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('启动交易员失败')
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/stop`,
|
||||
undefined,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('停止交易员失败')
|
||||
},
|
||||
|
||||
@@ -84,18 +86,19 @@ export const api = {
|
||||
traderId: string,
|
||||
customPrompt: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ custom_prompt: customPrompt }),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}/prompt`,
|
||||
{ custom_prompt: customPrompt },
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新自定义策略失败')
|
||||
},
|
||||
|
||||
async getTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/traders/${traderId}/config`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -104,52 +107,66 @@ export const api = {
|
||||
traderId: string,
|
||||
request: CreateTraderRequest
|
||||
): Promise<TraderInfo> {
|
||||
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// AI模型配置接口
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取模型配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
async getSupportedModels(): Promise<AIModel[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-models`)
|
||||
const res = await httpClient.get(`${API_BASE}/supported-models`)
|
||||
if (!res.ok) throw new Error('获取支持的模型失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/models`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/models`,
|
||||
encryptedPayload,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新模型配置失败')
|
||||
},
|
||||
|
||||
// 交易所配置接口
|
||||
async getExchangeConfigs(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取交易所配置失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取系统支持的交易所列表(无需认证)
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const res = await fetch(`${API_BASE}/supported-exchanges`)
|
||||
const res = await httpClient.get(`${API_BASE}/supported-exchanges`)
|
||||
if (!res.ok) throw new Error('获取支持的交易所失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -157,11 +174,41 @@ export const api = {
|
||||
async updateExchangeConfigs(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/exchanges`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/exchanges`,
|
||||
request,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 使用加密传输更新交易所配置
|
||||
async updateExchangeConfigsEncrypted(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const res = await httpClient.put(
|
||||
`${API_BASE}/exchanges`,
|
||||
encryptedPayload,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
@@ -170,9 +217,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取系统状态失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -182,7 +227,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/account?trader_id=${traderId}`
|
||||
: `${API_BASE}/account`
|
||||
const res = await fetch(url, {
|
||||
const res = await httpClient.request(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
@@ -200,9 +245,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取持仓列表失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -212,21 +255,26 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取决策日志失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取最新决策(支持trader_id)
|
||||
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions/latest`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
// 获取最新决策(支持trader_id和limit参数)
|
||||
async getLatestDecisions(
|
||||
traderId?: string,
|
||||
limit: number = 5
|
||||
): Promise<DecisionRecord[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (traderId) {
|
||||
params.append('trader_id', traderId)
|
||||
}
|
||||
params.append('limit', limit.toString())
|
||||
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/decisions/latest?${params}`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取最新决策失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -236,9 +284,7 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取统计信息失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -248,21 +294,15 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取历史数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 批量获取多个交易员的历史数据(无需认证)
|
||||
async getEquityHistoryBatch(traderIds: string[]): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/equity-history-batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ trader_ids: traderIds }),
|
||||
const res = await httpClient.post(`${API_BASE}/equity-history-batch`, {
|
||||
trader_ids: traderIds,
|
||||
})
|
||||
if (!res.ok) throw new Error('获取批量历史数据失败')
|
||||
return res.json()
|
||||
@@ -270,14 +310,14 @@ export const api = {
|
||||
|
||||
// 获取前5名交易员数据(无需认证)
|
||||
async getTopTraders(): Promise<any[]> {
|
||||
const res = await fetch(`${API_BASE}/top-traders`)
|
||||
const res = await httpClient.get(`${API_BASE}/top-traders`)
|
||||
if (!res.ok) throw new Error('获取前5名交易员失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取公开交易员配置(无需认证)
|
||||
async getPublicTraderConfig(traderId: string): Promise<any> {
|
||||
const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
|
||||
const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`)
|
||||
if (!res.ok) throw new Error('获取公开交易员配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -287,16 +327,14 @@ export const api = {
|
||||
const url = traderId
|
||||
? `${API_BASE}/performance?trader_id=${traderId}`
|
||||
: `${API_BASE}/performance`
|
||||
const res = await fetch(url, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(url, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取AI学习数据失败')
|
||||
return res.json()
|
||||
},
|
||||
|
||||
// 获取竞赛数据(无需认证)
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const res = await fetch(`${API_BASE}/competition`)
|
||||
const res = await httpClient.get(`${API_BASE}/competition`)
|
||||
if (!res.ok) throw new Error('获取竞赛数据失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -306,9 +344,10 @@ export const api = {
|
||||
coin_pool_url: string
|
||||
oi_top_url: string
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
const res = await httpClient.get(
|
||||
`${API_BASE}/user/signal-sources`,
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('获取用户信号源配置失败')
|
||||
return res.json()
|
||||
},
|
||||
@@ -317,26 +356,24 @@ export const api = {
|
||||
coinPoolUrl: string,
|
||||
oiTopUrl: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/user/signal-sources`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
const res = await httpClient.post(
|
||||
`${API_BASE}/user/signal-sources`,
|
||||
{
|
||||
coin_pool_url: coinPoolUrl,
|
||||
oi_top_url: oiTopUrl,
|
||||
}),
|
||||
})
|
||||
},
|
||||
getAuthHeaders()
|
||||
)
|
||||
if (!res.ok) throw new Error('保存用户信号源配置失败')
|
||||
},
|
||||
|
||||
// 获取服务器IP(需要认证,用于白名单配置)
|
||||
async getServerIP(): Promise<{
|
||||
public_ip: string;
|
||||
message: string;
|
||||
public_ip: string
|
||||
message: string
|
||||
}> {
|
||||
const res = await fetch(`${API_BASE}/server-ip`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('获取服务器IP失败');
|
||||
return res.json();
|
||||
const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders())
|
||||
if (!res.ok) throw new Error('获取服务器IP失败')
|
||||
return res.json()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
370
web/src/lib/apiGuard.test.ts
Normal file
370
web/src/lib/apiGuard.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* PR #669 測試: 防止 null token 導致未授權的 API 調用
|
||||
*
|
||||
* 問題:當用戶未登入時(user/token 為 null),SWR 仍然會使用空 key 發起 API 請求
|
||||
* 修復:在 SWR key 中添加 `user && token` 檢查,當未登入時返回 null,阻止 API 調用
|
||||
*/
|
||||
|
||||
describe('API Guard Logic (PR #669)', () => {
|
||||
/**
|
||||
* 測試 SWR key 生成邏輯
|
||||
* 核心修復:key 必須包含 user && token 檢查
|
||||
*/
|
||||
describe('SWR key generation', () => {
|
||||
it('should return null when user is null', () => {
|
||||
const user = null
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when token is null', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when both user and token are null', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when currentPage is not trader', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage: string = 'competition' // Not 'trader', so key should be null
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when traderId is not set', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = null
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should return valid key when all conditions are met', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBe('status-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試不同 API 端點的條件邏輯
|
||||
* 所有需要認證的端點都應該檢查 user && token
|
||||
*/
|
||||
describe('multiple API endpoints', () => {
|
||||
it('should guard status API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statusKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statusKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard account API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const accountKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `account-${traderId}`
|
||||
: null
|
||||
|
||||
expect(accountKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard positions API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const positionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `positions-${traderId}`
|
||||
: null
|
||||
|
||||
expect(positionsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard decisions API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const decisionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `decisions/latest-${traderId}`
|
||||
: null
|
||||
|
||||
expect(decisionsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should guard statistics API', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `statistics-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statsKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should allow all API calls when authenticated', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const statusKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
const accountKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `account-${traderId}`
|
||||
: null
|
||||
const positionsKey =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `positions-${traderId}`
|
||||
: null
|
||||
|
||||
expect(statusKey).toBe('status-123')
|
||||
expect(accountKey).toBe('account-123')
|
||||
expect(positionsKey).toBe('positions-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試 EquityChart 組件的條件邏輯
|
||||
* PR #669 同時修復了 EquityChart 中的相同問題
|
||||
*/
|
||||
describe('EquityChart API guard', () => {
|
||||
it('should return null key when user is not authenticated', () => {
|
||||
const user = null
|
||||
const token = null
|
||||
const traderId = '123'
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null key when traderId is missing', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = null
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBeNull()
|
||||
})
|
||||
|
||||
it('should return valid key when authenticated with traderId', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
|
||||
const equityKey =
|
||||
user && token && traderId ? `equity-history-${traderId}` : null
|
||||
const accountKey =
|
||||
user && token && traderId ? `account-${traderId}` : null
|
||||
|
||||
expect(equityKey).toBe('equity-history-123')
|
||||
expect(accountKey).toBe('account-123')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試邊界情況和特殊值
|
||||
*/
|
||||
describe('edge cases', () => {
|
||||
it('should treat empty string token as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = ''
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should treat empty string traderId as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = ''
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined user', () => {
|
||||
const user = undefined
|
||||
const token = 'valid-token'
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined token', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = undefined
|
||||
const traderId = '123'
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle numeric traderId', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = 123 // 數字而非字串
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBe('status-123')
|
||||
})
|
||||
|
||||
it('should handle zero traderId as falsy', () => {
|
||||
const user = { id: '1', email: 'test@example.com' }
|
||||
const token = 'valid-token'
|
||||
const traderId = 0
|
||||
const currentPage = 'trader'
|
||||
|
||||
const key =
|
||||
user && token && currentPage === 'trader' && traderId
|
||||
? `status-${traderId}`
|
||||
: null
|
||||
|
||||
expect(key).toBeNull() // 0 is falsy
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 測試防止 API 調用的邏輯流程
|
||||
*/
|
||||
describe('API call prevention flow', () => {
|
||||
it('should prevent API call when key is null', () => {
|
||||
const key = null
|
||||
const shouldCallAPI = key !== null
|
||||
|
||||
expect(shouldCallAPI).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow API call when key is valid', () => {
|
||||
const key = 'status-123'
|
||||
const shouldCallAPI = key !== null
|
||||
|
||||
expect(shouldCallAPI).toBe(true)
|
||||
})
|
||||
|
||||
it('should simulate SWR behavior with null key', () => {
|
||||
// SWR 不會在 key 為 null 時發起請求
|
||||
const key = null
|
||||
const fetcher = (k: string) => `API response for ${k}`
|
||||
|
||||
// 模擬 SWR 行為:key 為 null 時不調用 fetcher
|
||||
const data = key ? fetcher(key) : undefined
|
||||
|
||||
expect(data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should simulate SWR behavior with valid key', () => {
|
||||
const key = 'status-123'
|
||||
const fetcher = (k: string) => `API response for ${k}`
|
||||
|
||||
const data = key ? fetcher(key) : undefined
|
||||
|
||||
expect(data).toBe('API response for status-123')
|
||||
})
|
||||
})
|
||||
})
|
||||
30
web/src/lib/clipboard.ts
Normal file
30
web/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { notify } from './notify'
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板,并显示轻量提示。
|
||||
*/
|
||||
export async function copyWithToast(text: string, successMsg = '已复制') {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// 兼容降级:创建临时文本域执行复制
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
notify.success(successMsg)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Clipboard copy failed:', err)
|
||||
notify.error('复制失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default { copyWithToast }
|
||||
7
web/src/lib/cn.ts
Normal file
7
web/src/lib/cn.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export interface SystemConfig {
|
||||
admin_mode: boolean
|
||||
beta_mode: boolean
|
||||
}
|
||||
|
||||
|
||||
235
web/src/lib/crypto.ts
Normal file
235
web/src/lib/crypto.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
export interface EncryptedPayload {
|
||||
wrappedKey: string // RSA-OAEP(K)
|
||||
iv: string // 12 bytes
|
||||
ciphertext: string // AES-GCM 输出(含 tag)
|
||||
aad?: string // 可选:额外认证数据
|
||||
kid?: string // 可选:服务端公钥标识
|
||||
ts?: number // 可选:unix 秒,用于重放保护
|
||||
}
|
||||
|
||||
export interface WebCryptoEnvironmentInfo {
|
||||
isBrowser: boolean
|
||||
isSecureContext: boolean
|
||||
hasSubtleCrypto: boolean
|
||||
origin?: string
|
||||
protocol?: string
|
||||
hostname?: string
|
||||
isLocalhost?: boolean
|
||||
}
|
||||
|
||||
export class CryptoService {
|
||||
private static publicKey: CryptoKey | null = null
|
||||
private static publicKeyPEM: string | null = null
|
||||
|
||||
static async initialize(publicKeyPEM: string) {
|
||||
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
|
||||
return
|
||||
}
|
||||
this.publicKeyPEM = publicKeyPEM
|
||||
this.publicKey = await this.importPublicKey(publicKeyPEM)
|
||||
}
|
||||
|
||||
private static async importPublicKey(pem: string): Promise<CryptoKey> {
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----'
|
||||
const pemFooter = '-----END PUBLIC KEY-----'
|
||||
const headerIndex = pem.indexOf(pemHeader)
|
||||
const footerIndex = pem.indexOf(pemFooter)
|
||||
|
||||
if (
|
||||
headerIndex === -1 ||
|
||||
footerIndex === -1 ||
|
||||
headerIndex >= footerIndex
|
||||
) {
|
||||
throw new Error('Invalid PEM formatted public key')
|
||||
}
|
||||
|
||||
const pemContents = pem
|
||||
.substring(headerIndex + pemHeader.length, footerIndex)
|
||||
.replace(/\s+/g, '') // 移除所有空白字符(包括换行符、空格等)
|
||||
|
||||
const binaryDerString = atob(pemContents)
|
||||
const binaryDer = new Uint8Array(binaryDerString.length)
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
binaryDer,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
}
|
||||
|
||||
static async encryptSensitiveData(
|
||||
plaintext: string,
|
||||
userId?: string,
|
||||
sessionId?: string
|
||||
): Promise<EncryptedPayload> {
|
||||
if (!this.publicKey) {
|
||||
throw new Error(
|
||||
'Crypto service not initialized. Call initialize() first.'
|
||||
)
|
||||
}
|
||||
|
||||
// 1. 生成 256-bit AES 密钥
|
||||
const aesKey = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256,
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
)
|
||||
|
||||
// 2. 生成 12 字节随机 IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
|
||||
// 3. 准备 AAD (额外认证数据)
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
const aadObject = {
|
||||
userId: userId || '',
|
||||
sessionId: sessionId || '',
|
||||
ts: ts,
|
||||
purpose: 'sensitive_data_encryption',
|
||||
}
|
||||
const aadString = JSON.stringify(aadObject)
|
||||
const aadBytes = new TextEncoder().encode(aadString)
|
||||
|
||||
// 4. 使用 AES-GCM 加密数据
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
additionalData: aadBytes,
|
||||
tagLength: 128, // 16 bytes tag
|
||||
},
|
||||
aesKey,
|
||||
plaintextBytes
|
||||
)
|
||||
|
||||
// 5. 导出 AES 密钥
|
||||
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey)
|
||||
|
||||
// 6. 使用 RSA-OAEP 加密 AES 密钥
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
},
|
||||
this.publicKey,
|
||||
rawAesKey
|
||||
)
|
||||
|
||||
// 7. 编码为 base64url
|
||||
return {
|
||||
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
|
||||
iv: this.arrayBufferToBase64Url(iv.buffer),
|
||||
ciphertext: this.arrayBufferToBase64Url(ciphertext),
|
||||
aad: this.arrayBufferToBase64Url(aadBytes.buffer),
|
||||
ts: ts,
|
||||
}
|
||||
}
|
||||
|
||||
private static arrayBufferToBase64Url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
static async fetchPublicKey(): Promise<string> {
|
||||
const response = await fetch('/api/crypto/public-key')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch public key: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.public_key
|
||||
}
|
||||
|
||||
static async decryptSensitiveData(
|
||||
payload: EncryptedPayload
|
||||
): Promise<string> {
|
||||
const response = await fetch('/api/crypto/decrypt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Decryption failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.plaintext
|
||||
}
|
||||
}
|
||||
|
||||
// 生成混淆字符串(用于剪贴板混淆)
|
||||
export function generateObfuscation(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
// 验证私钥格式
|
||||
export function validatePrivateKeyFormat(
|
||||
value: string,
|
||||
expectedLength: number = 64
|
||||
): boolean {
|
||||
const normalized = value.startsWith('0x') ? value.slice(2) : value
|
||||
if (normalized.length !== expectedLength) {
|
||||
return false
|
||||
}
|
||||
return /^[0-9a-fA-F]+$/.test(normalized)
|
||||
}
|
||||
|
||||
export function diagnoseWebCryptoEnvironment(): WebCryptoEnvironmentInfo {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
isBrowser: false,
|
||||
isSecureContext: false,
|
||||
hasSubtleCrypto: false,
|
||||
}
|
||||
}
|
||||
|
||||
const { location } = window
|
||||
const hostname = location?.hostname
|
||||
const protocol = location?.protocol
|
||||
const origin = location?.origin
|
||||
const isLocalhost = hostname
|
||||
? ['localhost', '127.0.0.1', '::1'].includes(hostname)
|
||||
: false
|
||||
|
||||
const secureContext =
|
||||
typeof window.isSecureContext === 'boolean'
|
||||
? window.isSecureContext
|
||||
: protocol === 'https:' || (protocol === 'http:' && isLocalhost)
|
||||
|
||||
const hasSubtleCrypto =
|
||||
typeof window.crypto !== 'undefined' &&
|
||||
typeof window.crypto.subtle !== 'undefined'
|
||||
|
||||
return {
|
||||
isBrowser: true,
|
||||
isSecureContext: secureContext,
|
||||
hasSubtleCrypto,
|
||||
origin: origin || undefined,
|
||||
protocol: protocol || undefined,
|
||||
hostname,
|
||||
isLocalhost,
|
||||
}
|
||||
}
|
||||
169
web/src/lib/httpClient.ts
Normal file
169
web/src/lib/httpClient.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* HTTP Client with unified error handling and 401 interception
|
||||
*
|
||||
* Features:
|
||||
* - Unified fetch wrapper
|
||||
* - Automatic 401 token expiration handling
|
||||
* - Auth state cleanup on unauthorized
|
||||
* - Automatic redirect to login page
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export class HttpClient {
|
||||
// Singleton flag to prevent duplicate 401 handling
|
||||
private static isHandling401 = false
|
||||
|
||||
/**
|
||||
* Reset 401 handling flag (call after successful login)
|
||||
*/
|
||||
public reset401Flag(): void {
|
||||
HttpClient.isHandling401 = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Show login required notification to user
|
||||
*/
|
||||
private showLoginRequiredNotification(): void {
|
||||
toast.warning('登录已过期,请先登录', { duration: 1800 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Response interceptor - handles common HTTP errors
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns Response if successful
|
||||
* @throws Error with user-friendly message
|
||||
*/
|
||||
private async handleResponse(response: Response): Promise<Response> {
|
||||
// Handle 401 Unauthorized - Token expired or invalid
|
||||
if (response.status === 401) {
|
||||
// Prevent duplicate 401 handling when multiple API calls fail simultaneously
|
||||
if (HttpClient.isHandling401) {
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// Set flag to prevent race conditions
|
||||
HttpClient.isHandling401 = true
|
||||
|
||||
// Clean up local storage
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
|
||||
// Notify global listeners (AuthContext will react to this)
|
||||
window.dispatchEvent(new Event('unauthorized'))
|
||||
|
||||
// Show user-friendly notification (only once)
|
||||
this.showLoginRequiredNotification()
|
||||
|
||||
// Delay redirect to let user see the notification
|
||||
setTimeout(() => {
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
// Save current location for post-login redirect
|
||||
const returnUrl = window.location.pathname + window.location.search
|
||||
if (returnUrl !== '/login' && returnUrl !== '/') {
|
||||
sessionStorage.setItem('returnUrl', returnUrl)
|
||||
}
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// Note: No need to reset flag since we're redirecting
|
||||
}, 1500) // 1.5秒延迟,让用户看到提示
|
||||
|
||||
throw new Error('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
// Handle other common errors
|
||||
if (response.status === 403) {
|
||||
throw new Error('没有权限访问此资源')
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('请求的资源不存在')
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
throw new Error('服务器错误,请稍后重试')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get(url: string, headers?: Record<string, string>): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(
|
||||
url: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic request method for custom configurations
|
||||
*/
|
||||
async request(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(url, options)
|
||||
return this.handleResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const httpClient = new HttpClient()
|
||||
|
||||
// Export helper function to reset 401 flag
|
||||
export const reset401Flag = () => httpClient.reset401Flag()
|
||||
87
web/src/lib/notify.tsx
Normal file
87
web/src/lib/notify.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string
|
||||
message?: string
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
// 全局 confirm 函数的引用,将在 ConfirmDialogProvider 中设置
|
||||
let globalConfirm:
|
||||
| ((options: ConfirmOptions & { message: string }) => Promise<boolean>)
|
||||
| null = null
|
||||
|
||||
export function setGlobalConfirm(
|
||||
confirmFn: (options: ConfirmOptions & { message: string }) => Promise<boolean>
|
||||
) {
|
||||
globalConfirm = confirmFn
|
||||
}
|
||||
|
||||
// 确认对话框函数,使用 shadcn AlertDialog
|
||||
export function confirmToast(
|
||||
message: string,
|
||||
options: ConfirmOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (!globalConfirm) {
|
||||
console.error('ConfirmDialogProvider not initialized')
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return globalConfirm({
|
||||
message,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// 统一通知封装,避免组件直接依赖 sonner
|
||||
type Message = string | ReactNode
|
||||
|
||||
function message(msg: Message, options?: Parameters<typeof toast>[1]) {
|
||||
return toast(msg as any, options)
|
||||
}
|
||||
|
||||
function success(msg: Message, options?: Parameters<typeof toast.success>[1]) {
|
||||
return toast.success(msg as any, options)
|
||||
}
|
||||
|
||||
function error(msg: Message, options?: Parameters<typeof toast.error>[1]) {
|
||||
return toast.error(msg as any, options)
|
||||
}
|
||||
|
||||
function info(msg: Message, options?: Parameters<typeof toast.info>[1]) {
|
||||
return toast.info?.(msg as any, options) ?? toast(msg as any, options)
|
||||
}
|
||||
|
||||
function warning(msg: Message, options?: Parameters<typeof toast.warning>[1]) {
|
||||
return toast.warning?.(msg as any, options) ?? toast(msg as any, options)
|
||||
}
|
||||
|
||||
function custom(
|
||||
renderer: Parameters<typeof toast.custom>[0],
|
||||
options?: Parameters<typeof toast.custom>[1]
|
||||
) {
|
||||
return toast.custom(renderer, options)
|
||||
}
|
||||
|
||||
function dismiss(id?: string | number) {
|
||||
return toast.dismiss(id as any)
|
||||
}
|
||||
|
||||
function promise<T>(p: Promise<T> | (() => Promise<T>), msgs: any) {
|
||||
return toast.promise<T>(p as any, msgs as any)
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
message,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning,
|
||||
custom,
|
||||
dismiss,
|
||||
promise,
|
||||
}
|
||||
|
||||
export default { confirmToast, notify }
|
||||
28
web/src/lib/text.ts
Normal file
28
web/src/lib/text.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 文本工具
|
||||
*
|
||||
* stripLeadingIcons: 去掉翻译文案或标题前面用于装饰的 Emoji/符号,
|
||||
* 以便在组件里自行放置图标时不重复显示。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 去掉开头的装饰性 Emoji/符号以及随后的分隔符(空格/冒号/点号等)。
|
||||
*/
|
||||
export function stripLeadingIcons(input: string | undefined | null): string {
|
||||
if (!input) return ''
|
||||
let s = String(input)
|
||||
|
||||
// 1) 去除常见的 Emoji/符号块(箭头、杂项符号、几何图形、表情等)
|
||||
// 覆盖常见范围,兼容性好于使用 Unicode 属性类。
|
||||
s = s.replace(
|
||||
/^[\s\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u25A0-\u25FF\u2600-\u27BF\u2B00-\u2BFF\u1F000-\u1FAFF]+/u,
|
||||
''
|
||||
)
|
||||
|
||||
// 2) 去掉开头可能残留的分隔符(空格、连字符、冒号、居中点等)
|
||||
s = s.replace(/^[\s\-:•·]+/, '')
|
||||
|
||||
return s.trim()
|
||||
}
|
||||
|
||||
export default { stripLeadingIcons }
|
||||
@@ -1,10 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { Toaster } from 'sonner'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Toaster
|
||||
theme="dark"
|
||||
richColors
|
||||
closeButton
|
||||
position="top-center"
|
||||
duration={2200}
|
||||
toastOptions={{
|
||||
className: 'nofx-toast',
|
||||
style: {
|
||||
background: '#0b0e11',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--text-primary)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
21
web/src/pages/FAQPage.tsx
Normal file
21
web/src/pages/FAQPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FAQLayout } from '../components/faq/FAQLayout'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
/**
|
||||
* FAQ 页面
|
||||
*
|
||||
* HeaderBar 和 Footer 现在由 MainLayout 提供
|
||||
*
|
||||
* 所有 FAQ 相关的逻辑都在子组件中:
|
||||
* - FAQLayout: 整体布局和搜索逻辑
|
||||
* - FAQSearchBar: 搜索框
|
||||
* - FAQSidebar: 左侧目录
|
||||
* - FAQContent: 右侧内容区
|
||||
*
|
||||
* FAQ 数据配置在 data/faqData.ts
|
||||
*/
|
||||
export function FAQPage() {
|
||||
const { language } = useLanguage()
|
||||
|
||||
return <FAQLayout language={language} />
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import HeaderBar from '../components/landing/HeaderBar'
|
||||
import HeaderBar from '../components/HeaderBar'
|
||||
import HeroSection from '../components/landing/HeroSection'
|
||||
import AboutSection from '../components/landing/AboutSection'
|
||||
import FeaturesSection from '../components/landing/FeaturesSection'
|
||||
|
||||
983
web/src/pages/TraderDashboard.tsx
Normal file
983
web/src/pages/TraderDashboard.tsx
Normal file
@@ -0,0 +1,983 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import { EquityChart } from '../components/EquityChart'
|
||||
import AILearning from '../components/AILearning'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Brain,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
PieChart,
|
||||
Inbox,
|
||||
Send,
|
||||
Check,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { stripLeadingIcons } from '../lib/text'
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
Position,
|
||||
DecisionRecord,
|
||||
Statistics,
|
||||
TraderInfo,
|
||||
} from '../types'
|
||||
|
||||
// 获取友好的AI模型名称
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
switch (modelId.toLowerCase()) {
|
||||
case 'deepseek':
|
||||
return 'DeepSeek'
|
||||
case 'qwen':
|
||||
return 'Qwen'
|
||||
case 'claude':
|
||||
return 'Claude'
|
||||
default:
|
||||
return modelId.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
export default function TraderDashboard() {
|
||||
const { language } = useLanguage()
|
||||
const { user, token } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>(
|
||||
searchParams.get('trader') || undefined
|
||||
)
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||
|
||||
// 决策记录数量选择(从 localStorage 读取,默认 5)
|
||||
const [decisionLimit, setDecisionLimit] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('decisionLimit')
|
||||
return saved ? parseInt(saved, 10) : 5
|
||||
})
|
||||
|
||||
// 当 limit 变化时保存到 localStorage
|
||||
const handleLimitChange = (newLimit: number) => {
|
||||
setDecisionLimit(newLimit)
|
||||
localStorage.setItem('decisionLimit', newLimit.toString())
|
||||
}
|
||||
|
||||
// 获取trader列表(仅在用户登录时)
|
||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
api.getTraders,
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
// 当获取到traders后,设置默认选中第一个
|
||||
useEffect(() => {
|
||||
if (traders && traders.length > 0 && !selectedTraderId) {
|
||||
const firstTraderId = traders[0].trader_id
|
||||
setSelectedTraderId(firstTraderId)
|
||||
setSearchParams({ trader: firstTraderId })
|
||||
}
|
||||
}, [traders, selectedTraderId, setSearchParams])
|
||||
|
||||
// 更新URL参数
|
||||
const handleTraderSelect = (traderId: string) => {
|
||||
setSelectedTraderId(traderId)
|
||||
setSearchParams({ trader: traderId })
|
||||
}
|
||||
|
||||
// 如果在trader页面,获取该trader的数据
|
||||
const { data: status } = useSWR<SystemStatus>(
|
||||
user && token && selectedTraderId ? `status-${selectedTraderId}` : null,
|
||||
() => api.getStatus(selectedTraderId),
|
||||
{
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: account } = useSWR<AccountInfo>(
|
||||
user && token && selectedTraderId ? `account-${selectedTraderId}` : null,
|
||||
() => api.getAccount(selectedTraderId),
|
||||
{
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: positions } = useSWR<Position[]>(
|
||||
user && token && selectedTraderId ? `positions-${selectedTraderId}` : null,
|
||||
() => api.getPositions(selectedTraderId),
|
||||
{
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: decisions } = useSWR<DecisionRecord[]>(
|
||||
user && token && selectedTraderId
|
||||
? `decisions/latest-${selectedTraderId}-${decisionLimit}`
|
||||
: null,
|
||||
() => api.getLatestDecisions(selectedTraderId, decisionLimit),
|
||||
{
|
||||
refreshInterval: 30000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: stats } = useSWR<Statistics>(
|
||||
user && token && selectedTraderId ? `statistics-${selectedTraderId}` : null,
|
||||
() => api.getStatistics(selectedTraderId),
|
||||
{
|
||||
refreshInterval: 30000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
)
|
||||
|
||||
// Avoid unused variable warning
|
||||
void stats
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
const now = new Date().toLocaleTimeString()
|
||||
setLastUpdate(now)
|
||||
}
|
||||
}, [account])
|
||||
|
||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
||||
|
||||
// If API failed with error, show empty state
|
||||
if (tradersError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div
|
||||
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '2px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#F0B90B' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('dashboardEmptyTitle', language)}
|
||||
</h2>
|
||||
<p className="text-base mb-6" style={{ color: '#848E9C' }}>
|
||||
{t('dashboardEmptyDescription', language)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/traders')}
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
boxShadow: '0 4px 12px rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
{t('goToTradersPage', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If traders is loaded and empty, show empty state
|
||||
if (traders && traders.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div
|
||||
className="w-24 h-24 mx-auto mb-6 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '2px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#F0B90B' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('dashboardEmptyTitle', language)}
|
||||
</h2>
|
||||
<p className="text-base mb-6" style={{ color: '#848E9C' }}>
|
||||
{t('dashboardEmptyDescription', language)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/traders')}
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
boxShadow: '0 4px 12px rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
{t('goToTradersPage', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If traders is still loading or selectedTrader is not ready, show skeleton
|
||||
if (!selectedTrader) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="binance-card p-6 animate-pulse">
|
||||
<div className="skeleton h-8 w-48 mb-3"></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="skeleton h-4 w-32"></div>
|
||||
<div className="skeleton h-4 w-24"></div>
|
||||
<div className="skeleton h-4 w-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="binance-card p-5 animate-pulse">
|
||||
<div className="skeleton h-4 w-24 mb-3"></div>
|
||||
<div className="skeleton h-8 w-32"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="binance-card p-6 animate-pulse">
|
||||
<div className="skeleton h-6 w-40 mb-4"></div>
|
||||
<div className="skeleton h-64 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Trader Header */}
|
||||
<div
|
||||
className="mb-6 rounded p-6 animate-scale-in"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h2
|
||||
className="text-2xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<span
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
}}
|
||||
>
|
||||
<Bot className="w-5 h-5" style={{ color: '#0B0E11' }} />
|
||||
</span>
|
||||
{selectedTrader.trader_name}
|
||||
</h2>
|
||||
|
||||
{/* Trader Selector */}
|
||||
{traders && traders.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('switchTrader', language)}:
|
||||
</span>
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => handleTraderSelect(e.target.value)}
|
||||
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id}>
|
||||
{trader.trader_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-4 text-sm"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
AI Model:{' '}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{
|
||||
color: selectedTrader.ai_model.includes('qwen')
|
||||
? '#c084fc'
|
||||
: '#60a5fa',
|
||||
}}
|
||||
>
|
||||
{getModelDisplayName(
|
||||
selectedTrader.ai_model.split('_').pop() ||
|
||||
selectedTrader.ai_model
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{status && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Cycles: {status.call_count}</span>
|
||||
<span>•</span>
|
||||
<span>Runtime: {status.runtime_minutes} min</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info */}
|
||||
{account && (
|
||||
<div
|
||||
className="mb-4 p-3 rounded text-xs font-mono"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div style={{ color: '#848E9C' }}>
|
||||
<RefreshCw className="inline w-4 h-4 mr-1 align-text-bottom" />
|
||||
Last Update: {lastUpdate} | Total Equity:{' '}
|
||||
{account?.total_equity?.toFixed(2) || '0.00'} | Available:{' '}
|
||||
{account?.available_balance?.toFixed(2) || '0.00'} | P&L:{' '}
|
||||
{account?.total_pnl?.toFixed(2) || '0.00'} (
|
||||
{account?.total_pnl_pct?.toFixed(2) || '0.00'}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
title={t('totalEquity', language)}
|
||||
value={`${account?.total_equity?.toFixed(2) || '0.00'} USDT`}
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) > 0}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('availableBalance', language)}
|
||||
value={`${account?.available_balance?.toFixed(2) || '0.00'} USDT`}
|
||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('totalPnL', language)}
|
||||
value={`${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'} USDT`}
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) >= 0}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('positions', language)}
|
||||
value={`${account?.position_count || 0}`}
|
||||
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区:左右分屏 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* 左侧:图表 + 持仓 */}
|
||||
<div className="space-y-6">
|
||||
{/* Equity Chart */}
|
||||
<div className="animate-slide-in" style={{ animationDelay: '0.1s' }}>
|
||||
<EquityChart traderId={selectedTrader.trader_id} />
|
||||
</div>
|
||||
|
||||
{/* Current Positions */}
|
||||
<div
|
||||
className="binance-card p-6 animate-slide-in"
|
||||
style={{ animationDelay: '0.15s' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2
|
||||
className="text-xl font-bold flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
{t('currentPositions', language)}
|
||||
</h2>
|
||||
{positions && positions.length > 0 && (
|
||||
<div
|
||||
className="text-xs px-3 py-1 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
color: '#F0B90B',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
{positions.length} {t('active', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{positions && positions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('symbol', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('side', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('entryPrice', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('markPrice', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('quantity', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('positionValue', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('leverage', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('unrealizedPnL', language)}
|
||||
</th>
|
||||
<th className="pb-3 font-semibold text-gray-400">
|
||||
{t('liqPrice', language)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-gray-800 last:border-0"
|
||||
>
|
||||
<td className="py-3 font-mono font-semibold">
|
||||
{pos.symbol}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-bold"
|
||||
style={
|
||||
pos.side === 'long'
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{t(
|
||||
pos.side === 'long' ? 'long' : 'short',
|
||||
language
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.entry_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.mark_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.quantity.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono font-bold"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{(pos.quantity * pos.mark_price).toFixed(2)} USDT
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{pos.leverage}x
|
||||
</td>
|
||||
<td className="py-3 font-mono">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl.toFixed(2)} (
|
||||
{pos.unrealized_pnl_pct.toFixed(2)}%)
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="py-3 font-mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{pos.liquidation_price.toFixed(4)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<div className="mb-4 opacity-50 flex justify-center">
|
||||
<PieChart className="w-16 h-16" />
|
||||
</div>
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{t('noPositions', language)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{t('noActivePositions', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:Recent Decisions */}
|
||||
<div
|
||||
className="binance-card p-6 animate-slide-in h-fit lg:sticky lg:top-24 lg:max-h-[calc(100vh-120px)]"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between mb-5 pb-4 border-b"
|
||||
style={{ borderColor: '#2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%)',
|
||||
boxShadow: '0 4px 14px rgba(99, 102, 241, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Brain className="w-5 h-5" style={{ color: '#FFFFFF' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('recentDecisions', language)}
|
||||
</h2>
|
||||
{decisions && decisions.length > 0 && (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('lastCycles', language, { count: decisions.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 显示数量选择器 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '显示' : 'Show'}:
|
||||
</span>
|
||||
<select
|
||||
value={decisionLimit}
|
||||
onChange={(e) => handleLimitChange(parseInt(e.target.value, 10))}
|
||||
className="rounded px-2 py-1 text-xs font-medium cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '条' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto pr-2"
|
||||
style={{ maxHeight: 'calc(100vh - 280px)' }}
|
||||
>
|
||||
{decisions && decisions.length > 0 ? (
|
||||
decisions.map((decision, i) => (
|
||||
<DecisionCard key={i} decision={decision} language={language} />
|
||||
))
|
||||
) : (
|
||||
<div className="py-16 text-center">
|
||||
<div className="mb-4 opacity-30 flex justify-center">
|
||||
<Brain className="w-16 h-16" />
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('noDecisionsYet', language)}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('aiDecisionsWillAppear', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Learning & Performance Analysis */}
|
||||
<div className="mb-6 animate-slide-in" style={{ animationDelay: '0.3s' }}>
|
||||
<AILearning traderId={selectedTrader.trader_id} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
positive,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
change?: number
|
||||
positive?: boolean
|
||||
subtitle?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-card animate-fade-in">
|
||||
<div
|
||||
className="text-xs mb-2 mono uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="text-2xl font-bold mb-1 mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="text-sm mono font-bold"
|
||||
style={{ color: positive ? '#0ECB81' : '#F6465D' }}
|
||||
>
|
||||
{positive ? '▲' : '▼'} {positive ? '+' : ''}
|
||||
{change.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-2 mono" style={{ color: '#848E9C' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Decision Card Component
|
||||
function DecisionCard({
|
||||
decision,
|
||||
language,
|
||||
}: {
|
||||
decision: DecisionRecord
|
||||
language: Language
|
||||
}) {
|
||||
const [showInputPrompt, setShowInputPrompt] = useState(false)
|
||||
const [showCoT, setShowCoT] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded p-5 transition-all duration-300 hover:translate-y-[-2px]"
|
||||
style={{
|
||||
border: '1px solid #2B3139',
|
||||
background: '#1E2329',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{t('cycle', language)} #{decision.cycle_number}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{new Date(decision.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-1 rounded text-xs font-bold"
|
||||
style={
|
||||
decision.success
|
||||
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
|
||||
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
|
||||
}
|
||||
>
|
||||
{t(decision.success ? 'success' : 'failed', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Prompt - Collapsible */}
|
||||
{decision.input_prompt && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setShowInputPrompt(!showInputPrompt)}
|
||||
className="flex items-center gap-2 text-sm transition-colors"
|
||||
style={{ color: '#60a5fa' }}
|
||||
>
|
||||
<span className="font-semibold flex items-center gap-2">
|
||||
<Inbox className="w-4 h-4" /> {t('inputPrompt', language)}
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{showInputPrompt
|
||||
? t('collapse', language)
|
||||
: t('expand', language)}
|
||||
</span>
|
||||
</button>
|
||||
{showInputPrompt && (
|
||||
<div
|
||||
className="mt-2 rounded p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{decision.input_prompt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chain of Thought - Collapsible */}
|
||||
{decision.cot_trace && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setShowCoT(!showCoT)}
|
||||
className="flex items-center gap-2 text-sm transition-colors"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<span className="font-semibold flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />{' '}
|
||||
{stripLeadingIcons(t('aiThinking', language))}
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{showCoT ? t('collapse', language) : t('expand', language)}
|
||||
</span>
|
||||
</button>
|
||||
{showCoT && (
|
||||
<div
|
||||
className="mt-2 rounded p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{decision.cot_trace}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decisions Actions */}
|
||||
{decision.decisions && decision.decisions.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{decision.decisions.map((action, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex items-center gap-2 text-sm rounded px-3 py-2"
|
||||
style={{ background: '#0B0E11' }}
|
||||
>
|
||||
<span
|
||||
className="font-mono font-bold"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{action.symbol}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-bold"
|
||||
style={
|
||||
action.action.includes('open')
|
||||
? {
|
||||
background: 'rgba(96, 165, 250, 0.1)',
|
||||
color: '#60a5fa',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
color: '#F0B90B',
|
||||
}
|
||||
}
|
||||
>
|
||||
{action.action}
|
||||
</span>
|
||||
{action.leverage > 0 && (
|
||||
<span style={{ color: '#F0B90B' }}>{action.leverage}x</span>
|
||||
)}
|
||||
{action.price > 0 && (
|
||||
<span
|
||||
className="font-mono text-xs"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
@{action.price.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ color: action.success ? '#0ECB81' : '#F6465D' }}>
|
||||
{action.success ? (
|
||||
<Check className="w-3 h-3 inline" />
|
||||
) : (
|
||||
<X className="w-3 h-3 inline" />
|
||||
)}
|
||||
</span>
|
||||
{action.error && (
|
||||
<span className="text-xs ml-2" style={{ color: '#F6465D' }}>
|
||||
{action.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account State Summary */}
|
||||
{decision.account_state && (
|
||||
<div
|
||||
className="flex gap-4 text-xs mb-3 rounded px-3 py-2"
|
||||
style={{ background: '#0B0E11', color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
净值: {decision.account_state.total_balance.toFixed(2)} USDT
|
||||
</span>
|
||||
<span>
|
||||
可用: {decision.account_state.available_balance.toFixed(2)} USDT
|
||||
</span>
|
||||
<span>
|
||||
保证金率: {decision.account_state.margin_used_pct.toFixed(1)}%
|
||||
</span>
|
||||
<span>持仓: {decision.account_state.position_count}</span>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
decision.candidate_coins &&
|
||||
decision.candidate_coins.length === 0
|
||||
? '#F6465D'
|
||||
: '#848E9C',
|
||||
}}
|
||||
>
|
||||
{t('candidateCoins', language)}:{' '}
|
||||
{decision.candidate_coins?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidate Coins Warning */}
|
||||
{decision.candidate_coins && decision.candidate_coins.length === 0 && (
|
||||
<div
|
||||
className="text-sm rounded px-4 py-3 mb-3 flex items-start gap-3"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1">
|
||||
{t('candidateCoinsZeroWarning', language)}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>{t('possibleReasons', language)}</div>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||
<li>{t('coinPoolApiNotConfigured', language)}</li>
|
||||
<li>{t('apiConnectionTimeout', language)}</li>
|
||||
<li>{t('noCustomCoinsAndApiFailed', language)}</li>
|
||||
</ul>
|
||||
<div className="mt-2">
|
||||
<strong>{t('solutions', language)}</strong>
|
||||
</div>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||
<li>{t('setCustomCoinsInConfig', language)}</li>
|
||||
<li>{t('orConfigureCorrectApiUrl', language)}</li>
|
||||
<li>{t('orDisableCoinPoolOptions', language)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Logs */}
|
||||
{decision.execution_log && decision.execution_log.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{decision.execution_log.map((log, k) => (
|
||||
<div
|
||||
key={k}
|
||||
className="text-xs font-mono"
|
||||
style={{
|
||||
color:
|
||||
log.includes('✓') || log.includes('成功')
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{decision.error_message && (
|
||||
<div
|
||||
className="text-sm rounded px-3 py-2 mt-3 flex items-center gap-2"
|
||||
style={{ color: '#F6465D', background: 'rgba(246, 70, 93, 0.1)' }}
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> {decision.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
web/src/routes/index.tsx
Normal file
62
web/src/routes/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import MainLayout from '../layouts/MainLayout'
|
||||
import AuthLayout from '../layouts/AuthLayout'
|
||||
import { LandingPage } from '../pages/LandingPage'
|
||||
import { FAQPage } from '../pages/FAQPage'
|
||||
import { LoginPage } from '../components/LoginPage'
|
||||
import { RegisterPage } from '../components/RegisterPage'
|
||||
import { ResetPasswordPage } from '../components/ResetPasswordPage'
|
||||
import { CompetitionPage } from '../components/CompetitionPage'
|
||||
import { AITradersPage } from '../components/AITradersPage'
|
||||
import TraderDashboard from '../pages/TraderDashboard'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <LandingPage />,
|
||||
},
|
||||
// Auth routes - using AuthLayout
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <RegisterPage />,
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
element: <ResetPasswordPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Main app routes - using MainLayout with nested routes
|
||||
{
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
path: '/faq',
|
||||
element: <FAQPage />,
|
||||
},
|
||||
{
|
||||
path: '/competition',
|
||||
element: <CompetitionPage />,
|
||||
},
|
||||
{
|
||||
path: '/traders',
|
||||
element: <AITradersPage />,
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: <TraderDashboard />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
])
|
||||
32
web/src/test/setup.ts
Normal file
32
web/src/test/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeAll, afterEach } from 'vitest'
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => {
|
||||
return localStorageMock._store[key] || null
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageMock._store[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageMock._store[key]
|
||||
},
|
||||
clear: () => {
|
||||
localStorageMock._store = {}
|
||||
},
|
||||
_store: {} as Record<string, string>,
|
||||
}
|
||||
|
||||
// Setup before all tests
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
localStorageMock.clear()
|
||||
})
|
||||
@@ -197,6 +197,7 @@ export interface TraderConfigData {
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
|
||||
Reference in New Issue
Block a user