Files
nofx/web/src/components/ChartTabs.tsx
tinkle-community e92222950a fix: use completeRegistration for incomplete OTP setup in login flow
- LoginPage: call completeRegistration instead of verifyOTP when qrCodeURL exists
- This ensures otp_verified is set to true for users completing OTP setup
- Backend: reorder maxUsers check to allow existing incomplete users to continue
- Backend: return OTP info when login with incomplete OTP setup
2026-01-07 20:15:27 +08:00

339 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { EquityChart } from './EquityChart'
import { AdvancedChart } from './AdvancedChart'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { BarChart3, CandlestickChart, ChevronDown, Search } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
interface ChartTabsProps {
traderId: string
selectedSymbol?: string // 从外部选择的币种
updateKey?: number // 强制更新的 key
exchangeId?: string // 交易所ID
}
type ChartTab = 'equity' | 'kline'
type Interval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d'
type MarketType = 'hyperliquid' | 'crypto' | 'stocks' | 'forex' | 'metals'
interface SymbolInfo {
symbol: string
name: string
category: string
}
// 市场类型配置
const MARKET_CONFIG = {
hyperliquid: { exchange: 'hyperliquid', defaultSymbol: 'BTC', icon: '🔷', label: { zh: 'HL', en: 'HL' }, color: 'cyan', hasDropdown: true },
crypto: { exchange: 'binance', defaultSymbol: 'BTCUSDT', icon: '₿', label: { zh: '加密', en: 'Crypto' }, color: 'yellow', hasDropdown: false },
stocks: { exchange: 'alpaca', defaultSymbol: 'AAPL', icon: '📈', label: { zh: '美股', en: 'Stocks' }, color: 'green', hasDropdown: false },
forex: { exchange: 'forex', defaultSymbol: 'EUR/USD', icon: '💱', label: { zh: '外汇', en: 'Forex' }, color: 'blue', hasDropdown: false },
metals: { exchange: 'metals', defaultSymbol: 'XAU/USD', icon: '🥇', label: { zh: '金属', en: 'Metals' }, color: 'amber', hasDropdown: false },
}
const INTERVALS: { value: Interval; label: string }[] = [
{ value: '1m', label: '1m' },
{ value: '5m', label: '5m' },
{ value: '15m', label: '15m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '1d', label: '1d' },
]
// 根据交易所ID推断市场类型
function getMarketTypeFromExchange(exchangeId: string | undefined): MarketType {
if (!exchangeId) return 'hyperliquid'
const lower = exchangeId.toLowerCase()
if (lower.includes('hyperliquid')) return 'hyperliquid'
// 其他交易所默认使用 crypto 类型
return 'crypto'
}
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
const [chartSymbol, setChartSymbol] = useState<string>('BTC')
const [interval, setInterval] = useState<Interval>('5m')
const [symbolInput, setSymbolInput] = useState('')
const [marketType, setMarketType] = useState<MarketType>(() => getMarketTypeFromExchange(exchangeId))
const [availableSymbols, setAvailableSymbols] = useState<SymbolInfo[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [searchFilter, setSearchFilter] = useState('')
const dropdownRef = useRef<HTMLDivElement>(null)
// 当交易所ID变化时自动切换市场类型
useEffect(() => {
const newMarketType = getMarketTypeFromExchange(exchangeId)
setMarketType(newMarketType)
}, [exchangeId])
// 根据市场类型确定交易所
const marketConfig = MARKET_CONFIG[marketType]
// 优先使用传入的 exchangeId非 hyperliquid 时)
const currentExchange = marketType === 'hyperliquid' ? 'hyperliquid' : (exchangeId || marketConfig.exchange)
// 获取可用币种列表
useEffect(() => {
if (marketConfig.hasDropdown) {
fetch(`/api/symbols?exchange=${marketConfig.exchange}`)
.then(res => res.json())
.then(data => {
if (data.symbols) {
// 按类别排序: crypto > stock > forex > commodity > index
const categoryOrder: Record<string, number> = { crypto: 0, stock: 1, forex: 2, commodity: 3, index: 4 }
const sorted = [...data.symbols].sort((a: SymbolInfo, b: SymbolInfo) => {
const orderA = categoryOrder[a.category] ?? 5
const orderB = categoryOrder[b.category] ?? 5
if (orderA !== orderB) return orderA - orderB
return a.symbol.localeCompare(b.symbol)
})
setAvailableSymbols(sorted)
}
})
.catch(err => console.error('Failed to fetch symbols:', err))
}
}, [marketType, marketConfig.exchange, marketConfig.hasDropdown])
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// 切换市场类型时更新默认符号
const handleMarketTypeChange = (type: MarketType) => {
setMarketType(type)
setChartSymbol(MARKET_CONFIG[type].defaultSymbol)
setShowDropdown(false)
}
// 过滤后的币种列表
const filteredSymbols = availableSymbols.filter(s =>
s.symbol.toLowerCase().includes(searchFilter.toLowerCase())
)
// 当从外部选择币种时自动切换到K线图
useEffect(() => {
if (selectedSymbol) {
console.log('[ChartTabs] 收到币种选择:', selectedSymbol, 'updateKey:', updateKey)
setChartSymbol(selectedSymbol)
setActiveTab('kline')
}
}, [selectedSymbol, updateKey])
// 处理手动输入符号
const handleSymbolSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (symbolInput.trim()) {
let symbol = symbolInput.trim().toUpperCase()
// 加密货币自动加 USDT 后缀
if (marketType === 'crypto' && !symbol.endsWith('USDT')) {
symbol = symbol + 'USDT'
}
setChartSymbol(symbol)
setSymbolInput('')
}
}
console.log('[ChartTabs] rendering, activeTab:', activeTab)
return (
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
}`}>
{/*
Premium Professional Toolbar
Mobile: Single row, horizontal scroll with gradient mask
Desktop: Standard flex-wrap/nowrap
*/}
<div
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
>
{/* Left: Tab Switcher */}
<div className="flex flex-wrap items-center gap-1">
<button
onClick={() => setActiveTab('equity')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
}`}
>
<BarChart3 className="w-3.5 h-3.5" />
<span className="hidden md:inline">{t('accountEquityCurve', language)}</span>
<span className="md:hidden">Eq</span>
</button>
<button
onClick={() => setActiveTab('kline')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'kline'
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
}`}
>
<CandlestickChart className="w-3.5 h-3.5" />
<span className="hidden md:inline">{t('marketChart', language)}</span>
<span className="md:hidden">Kline</span>
</button>
{/* Market Type Pills - Only when kline active, HIDDEN on mobile to save space */}
{activeTab === 'kline' && (
<div className="hidden md:flex items-center gap-1 ml-2 border-l border-white/10 pl-2">
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
const config = MARKET_CONFIG[type]
const isActive = marketType === type
return (
<button
key={type}
onClick={() => handleMarketTypeChange(type)}
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
? 'bg-white/10 text-white border-white/20'
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
}`}
>
<span className="mr-1 opacity-70">{config.icon}</span>
{language === 'zh' ? config.label.zh : config.label.en}
</button>
)
})}
</div>
)}
</div>
{/* Right: Symbol + Interval */}
{activeTab === 'kline' && (
<div className="flex items-center gap-2 md:gap-3 w-full md:w-auto min-w-0">
{/* Symbol Dropdown */}
<div className="shrink-0 relative" ref={dropdownRef}>
{marketConfig.hasDropdown ? (
<>
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main hover:border-nofx-gold/30 hover:text-nofx-gold transition-all"
>
<span>{chartSymbol}</span>
<ChevronDown className={`w-3 h-3 text-nofx-text-muted transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
</button>
{showDropdown && (
<div className="absolute top-full right-0 mt-2 w-64 bg-[#0B0E11] border border-white/10 rounded-lg shadow-[0_10px_40px_-10px_rgba(0,0,0,0.5)] z-50 overflow-hidden nofx-glass ring-1 ring-white/5">
<div className="p-2 border-b border-white/5">
<div className="flex items-center gap-2 px-2 py-1.5 bg-black/40 rounded border border-white/10 focus-within:border-nofx-gold/50 transition-colors">
<Search className="w-3.5 h-3.5 text-nofx-text-muted" />
<input
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder="Search symbol..."
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none font-mono"
autoFocus
/>
</div>
</div>
<div className="overflow-y-auto max-h-60 custom-scrollbar">
{['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => {
const categorySymbols = filteredSymbols.filter(s => s.category === category)
if (categorySymbols.length === 0) return null
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' }
return (
<div key={category}>
<div className="px-3 py-1.5 text-[9px] font-bold text-nofx-text-muted/60 bg-white/5 uppercase tracking-wider">{labels[category]}</div>
{categorySymbols.map(s => (
<button
key={s.symbol}
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
className={`w-full px-3 py-2 text-left text-[11px] font-mono hover:bg-white/5 transition-all flex items-center justify-between ${chartSymbol === s.symbol ? 'bg-nofx-gold/10 text-nofx-gold' : 'text-nofx-text-muted'}`}
>
<span>{s.symbol}</span>
<span className="text-[9px] opacity-40">{s.name}</span>
</button>
))}
</div>
)
})}
</div>
</div>
)}
</>
) : (
<span className="px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main font-mono">{chartSymbol}</span>
)}
</div>
{/* Interval Selector - Allow scrolling if needed */}
<div className="flex items-center bg-black/40 rounded border border-white/10 overflow-x-auto no-scrollbar max-w-[200px] md:max-w-none">
{INTERVALS.map((int) => (
<button
key={int.value}
onClick={() => setInterval(int.value)}
className={`px-2 py-1 text-[10px] font-medium transition-all ${interval === int.value
? 'bg-nofx-gold/20 text-nofx-gold'
: 'text-nofx-text-muted hover:text-white hover:bg-white/5'
}`}
>
{int.label}
</button>
))}
</div>
{/* Quick Input - Hidden on mobile, dropdown search is enough */}
<form onSubmit={handleSymbolSubmit} className="hidden md:flex items-center shrink-0">
<input
type="text"
value={symbolInput}
onChange={(e) => setSymbolInput(e.target.value)}
placeholder="Sym"
className="w-16 px-2 py-1 bg-black/40 border border-white/10 rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-nofx-gold/50 font-mono transition-colors"
/>
<button type="submit" className="px-2 py-1 bg-white/5 border border-white/10 border-l-0 rounded-r text-[10px] text-nofx-text-muted hover:text-white hover:bg-white/10 transition-all">
Go
</button>
</form>
</div>
)}
</div>
{/* Tab Content - Chart autosizes to this container */}
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden h-full min-h-0">
<AnimatePresence mode="wait">
{activeTab === 'equity' ? (
<motion.div
key="equity"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-full w-full absolute inset-0"
>
<EquityChart traderId={traderId} embedded />
</motion.div>
) : (
<motion.div
key={`kline-${chartSymbol}-${interval}-${currentExchange}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="h-full w-full absolute inset-0"
>
<AdvancedChart
symbol={chartSymbol}
interval={interval}
traderID={traderId}
// Dynamic auto-sizing via ResizeObserver
exchange={currentExchange}
onSymbolChange={setChartSymbol}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}