mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 06:46:59 +08:00
fix: prevent DeepSeek token overflow with product-level limits (#1431)
* feat: enforce strategy limits to prevent token overflow * fix: tune token limits after real-world testing - Relax kline max 20→30, timeframes 3→4 (tested ~41K tokens, safe under 131K) - Restore ranking limits to original [5,10,15,20] options (only ~1.5K token impact) - Add static coins limit (max 3) with toast notification - Add timeframe limit toast when exceeding 4 - Log SSE token usage (prompt/completion/total) from API response - Fix nil logger crash in claw402 data client (engine.go) * feat: add token estimation functionality for strategy configurations * feat: add discard changes button in Strategy Studio for unsaved modifications * feat: retain selected strategy after saving in Strategy Studio * feat: enhance strategy display in Strategy Studio with improved layout and sorting of token limits * refactor: improve layout and styling of stats display in CompetitionPage * refactor: replace select elements with NofxSelect component for improved consistency in strategy configuration forms * style: update NofxSelect component to use smaller text size for improved readability * feat: implement token overflow handling in strategy updates and UI --------- Co-authored-by: Dean <afei.wuhao@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
import { coinSource, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
config: CoinSourceConfig
|
||||
@@ -24,7 +25,6 @@ export function CoinSourceEditor({
|
||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
// Calculate mixed mode summary
|
||||
@@ -71,8 +71,26 @@ export function CoinSourceEditor({
|
||||
return xyzDexAssets.has(base)
|
||||
}
|
||||
|
||||
const MAX_STATIC_COINS = 3
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
const toast = document.createElement('div')
|
||||
toast.textContent = msg
|
||||
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
|
||||
toast.style.cssText = 'background:#F6465D;color:#fff;'
|
||||
document.body.appendChild(toast)
|
||||
setTimeout(() => toast.remove(), 2000)
|
||||
}
|
||||
|
||||
const handleAddCoin = () => {
|
||||
if (!newCoin.trim()) return
|
||||
|
||||
const currentCoins = config.static_coins || []
|
||||
if (currentCoins.length >= MAX_STATIC_COINS) {
|
||||
showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`)
|
||||
return
|
||||
}
|
||||
|
||||
const symbol = newCoin.toUpperCase().trim()
|
||||
|
||||
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
||||
@@ -85,7 +103,6 @@ export function CoinSourceEditor({
|
||||
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
}
|
||||
|
||||
const currentCoins = config.static_coins || []
|
||||
if (!currentCoins.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
...config,
|
||||
@@ -148,7 +165,7 @@ export function CoinSourceEditor({
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{ts(coinSource.sourceType, language)}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -309,19 +326,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.ai500Limit, language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) =>
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, ai500_limit: parseInt(val) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -366,19 +380,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiTopLimit, language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) =>
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_top_limit: parseInt(val) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -423,19 +434,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiLowLimit, language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) =>
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_low_limit: parseInt(val) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -483,20 +491,13 @@ export function CoinSourceEditor({
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 10 })}
|
||||
disabled={disabled}
|
||||
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,20 +531,13 @@ export function CoinSourceEditor({
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 10 })}
|
||||
disabled={disabled}
|
||||
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -577,20 +571,13 @@ export function CoinSourceEditor({
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 10 })}
|
||||
disabled={disabled}
|
||||
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
||||
import type { GridStrategyConfig } from '../../types'
|
||||
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
interface GridConfigEditorProps {
|
||||
config: GridStrategyConfig
|
||||
@@ -74,20 +75,21 @@ export function GridConfigEditor({
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{ts(gridConfig.symbolDesc, language)}
|
||||
</p>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.symbol}
|
||||
onChange={(e) => updateField('symbol', e.target.value)}
|
||||
onChange={(val) => updateField('symbol', val)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="BTCUSDT">BTC/USDT</option>
|
||||
<option value="ETHUSDT">ETH/USDT</option>
|
||||
<option value="SOLUSDT">SOL/USDT</option>
|
||||
<option value="BNBUSDT">BNB/USDT</option>
|
||||
<option value="XRPUSDT">XRP/USDT</option>
|
||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'BTCUSDT', label: 'BTC/USDT' },
|
||||
{ value: 'ETHUSDT', label: 'ETH/USDT' },
|
||||
{ value: 'SOLUSDT', label: 'SOL/USDT' },
|
||||
{ value: 'BNBUSDT', label: 'BNB/USDT' },
|
||||
{ value: 'XRPUSDT', label: 'XRP/USDT' },
|
||||
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Investment */}
|
||||
@@ -170,17 +172,18 @@ export function GridConfigEditor({
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{ts(gridConfig.distributionDesc, language)}
|
||||
</p>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.distribution}
|
||||
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
|
||||
onChange={(val) => updateField('distribution', val as 'uniform' | 'gaussian' | 'pyramid')}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="uniform">{ts(gridConfig.uniform, language)}</option>
|
||||
<option value="gaussian">{ts(gridConfig.gaussian, language)}</option>
|
||||
<option value="pyramid">{ts(gridConfig.pyramid, language)}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
|
||||
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
|
||||
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
import { indicator, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// Default NofxOS API Key
|
||||
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||
@@ -60,6 +61,16 @@ export function IndicatorEditor({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (current.length >= 4) {
|
||||
// Show toast notification
|
||||
const toast = document.createElement('div')
|
||||
toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed'
|
||||
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
|
||||
toast.style.cssText = 'background:#F6465D;color:#fff;'
|
||||
document.body.appendChild(toast)
|
||||
setTimeout(() => toast.remove(), 2000)
|
||||
return
|
||||
}
|
||||
current.push(tf)
|
||||
onChange({
|
||||
...config,
|
||||
@@ -299,26 +310,22 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.oi_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_duration: val })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.oi_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -359,26 +366,22 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
||||
{config.enable_netflow_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.netflow_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_duration: val })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.netflow_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -419,27 +422,27 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
||||
{config.enable_price_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.price_ranking_duration || '1h,4h,24h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, price_ranking_duration: val })}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h,4h,24h">{ts(indicator.priceRankingMulti, language)}</option>
|
||||
</select>
|
||||
<select
|
||||
options={[
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) },
|
||||
]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.price_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
|
||||
onChange={(val) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })}
|
||||
disabled={disabled}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -515,7 +518,7 @@ export function IndicatorEditor({
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={200}
|
||||
max={30}
|
||||
className="w-16 px-2 py-1 rounded text-xs text-center"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RiskControlEditor({
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
max={3}
|
||||
className="w-32 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
|
||||
143
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
143
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Info } from 'lucide-react'
|
||||
import type { StrategyConfig } from '../../types'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
interface ModelLimit {
|
||||
name: string
|
||||
context_limit: number
|
||||
usage_pct: number
|
||||
level: string
|
||||
}
|
||||
|
||||
interface TokenEstimateResult {
|
||||
total: number
|
||||
model_limits: ModelLimit[]
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
interface TokenEstimateBarProps {
|
||||
config: StrategyConfig | null
|
||||
language: Language
|
||||
onOverflowChange?: (overflow: boolean) => void
|
||||
}
|
||||
|
||||
export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEstimateBarProps) {
|
||||
const [estimate, setEstimate] = useState<TokenEstimateResult | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const tr = (key: string) => t(`strategyStudio.${key}`, language)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
setEstimate(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/estimate-tokens`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setEstimate(data)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore — non-critical UI element
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
if (!estimate) {
|
||||
onOverflowChange?.(false)
|
||||
return
|
||||
}
|
||||
const maxPct = estimate.model_limits.reduce((max, ml) => Math.max(max, ml.usage_pct), 0)
|
||||
onOverflowChange?.(maxPct >= 100)
|
||||
}, [estimate, onOverflowChange])
|
||||
|
||||
if (!config) return null
|
||||
|
||||
if (isLoading && !estimate) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-nofx-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{tr('tokenEstimating')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!estimate) return null
|
||||
|
||||
// Find the strictest model (smallest context limit = highest usage_pct)
|
||||
const strictest = estimate.model_limits.reduce(
|
||||
(max, ml) => (ml.usage_pct > max.usage_pct ? ml : max),
|
||||
estimate.model_limits[0]
|
||||
)
|
||||
if (!strictest) return null
|
||||
|
||||
const pct = strictest.usage_pct
|
||||
const barWidth = Math.min(pct, 100)
|
||||
|
||||
let barColor = '#0ECB81' // green
|
||||
let textColor = '#848E9C'
|
||||
if (pct >= 100) {
|
||||
barColor = '#F6465D' // red
|
||||
textColor = '#F6465D'
|
||||
} else if (pct >= 80) {
|
||||
barColor = '#F0B90B' // yellow
|
||||
textColor = '#F0B90B'
|
||||
}
|
||||
|
||||
const exceedWarning = pct >= 100 ? tr('tokenExceedWarning') : null
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex-1 h-1.5 rounded-full overflow-hidden"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${barWidth}%`, background: barColor }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono whitespace-nowrap" style={{ color: textColor }}>
|
||||
{isLoading ? <Loader2 className="w-3 h-3 animate-spin inline" /> : `${pct}%`}
|
||||
</span>
|
||||
<div className="relative group">
|
||||
<Info className="w-3 h-3 text-nofx-text-muted cursor-help" />
|
||||
<div className="absolute bottom-full right-0 mb-1.5 px-2.5 py-1.5 rounded-lg text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 bg-nofx-bg-lighter border border-nofx-border text-nofx-text-muted shadow-lg">
|
||||
{tr('tokenTooltip')} ({strictest.name} {(strictest.context_limit / 1000).toFixed(0)}K)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{exceedWarning && (
|
||||
<p className="text-[10px]" style={{ color: '#F6465D' }}>
|
||||
{exceedWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -281,14 +281,14 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
||||
<div className="flex items-center gap-4 md:gap-6">
|
||||
{/* Total Equity */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right min-w-[60px] md:min-w-[80px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
@@ -297,11 +297,11 @@ export function CompetitionPage() {
|
||||
|
||||
{/* P&L */}
|
||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('pnl', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
@@ -313,7 +313,7 @@ export function CompetitionPage() {
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono"
|
||||
className="text-[10px] mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
@@ -322,17 +322,17 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right min-w-[40px] md:min-w-[50px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { MetricTooltip } from '../common/MetricTooltip'
|
||||
import { formatPrice, formatQuantity } from '../../utils/format'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
import type {
|
||||
HistoricalPosition,
|
||||
TraderStats,
|
||||
@@ -664,23 +665,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.symbol', language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={filterSymbol}
|
||||
onChange={(e) => setFilterSymbol(e.target.value)}
|
||||
onChange={(val) => setFilterSymbol(val)}
|
||||
options={[
|
||||
{ value: 'all', label: t('positionHistory.allSymbols', language) },
|
||||
...uniqueSymbols.map(s => ({ value: s, label: (s || '').replace('USDT', '') }))
|
||||
]}
|
||||
className="rounded px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value="all">{t('positionHistory.allSymbols', language)}</option>
|
||||
{uniqueSymbols.map((symbol) => (
|
||||
<option key={symbol} value={symbol}>
|
||||
{(symbol || '').replace('USDT', '')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -708,28 +706,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.sort', language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [by, order] = e.target.value.split('-') as [
|
||||
'time' | 'pnl' | 'pnl_pct',
|
||||
'asc' | 'desc',
|
||||
]
|
||||
onChange={(val) => {
|
||||
const [by, order] = val.split('-') as ['time' | 'pnl' | 'pnl_pct', 'asc' | 'desc']
|
||||
setSortBy(by)
|
||||
setSortOrder(order)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'time-desc', label: t('positionHistory.latestFirst', language) },
|
||||
{ value: 'time-asc', label: t('positionHistory.oldestFirst', language) },
|
||||
{ value: 'pnl-desc', label: t('positionHistory.highestPnL', language) },
|
||||
{ value: 'pnl-asc', label: t('positionHistory.lowestPnL', language) },
|
||||
]}
|
||||
className="rounded px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value="time-desc">{t('positionHistory.latestFirst', language)}</option>
|
||||
<option value="time-asc">{t('positionHistory.oldestFirst', language)}</option>
|
||||
<option value="pnl-desc">{t('positionHistory.highestPnL', language)}</option>
|
||||
<option value="pnl-asc">{t('positionHistory.lowestPnL', language)}</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -841,20 +837,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '每页' : 'Per page'}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
onChange={(val) => setPageSize(Number(val))}
|
||||
options={[
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' },
|
||||
]}
|
||||
className="rounded px-2 py-1 text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { api } from '../../lib/api'
|
||||
import type { TelegramConfig, AIModel } from '../../types'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||
@@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr
|
||||
{t('telegram.noEnabledModels', language)}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
<NofxSelect
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
||||
onChange={(val) => setSelectedModelId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('telegram.autoSelect', language) },
|
||||
...models.map(m => ({ value: m.id, label: `${m.name} (${m.provider}${m.customModelName ? ` · ${m.customModelName}` : ''})` }))
|
||||
]}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('telegram.autoSelect', language)}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('telegram.autoUseEnabled', language)}
|
||||
@@ -489,23 +487,20 @@ function BoundModelSelector({
|
||||
{t('telegram.aiModelLabel', language)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
<NofxSelect
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
||||
onChange={(val) => setModelId(val)}
|
||||
options={[
|
||||
{ value: '', label: t('telegram.aiModelAutoSelect', language) },
|
||||
...models.map(m => ({ value: m.id, label: `${m.name}${m.customModelName ? ` · ${m.customModelName}` : ''}` }))
|
||||
]}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: modelId ? '#EAECEF' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('telegram.aiModelAutoSelect', language)}</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || modelId === currentModelId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { t } from '../../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
||||
import { httpClient } from '../../lib/httpClient'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
@@ -250,38 +251,34 @@ export function TraderConfigModal({
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('aiModelRequired', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.ai_model}
|
||||
onChange={(e) =>
|
||||
handleInputChange('ai_model', e.target.value)
|
||||
onChange={(val) =>
|
||||
handleInputChange('ai_model', val)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name || model.id).toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={availableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: getShortName(model.name || model.id).toUpperCase(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('exchangeRequired', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.exchange_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('exchange_id', e.target.value)
|
||||
onChange={(val) =>
|
||||
handleInputChange('exchange_id', val)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
||||
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={availableExchanges.map((exchange) => ({
|
||||
value: exchange.id,
|
||||
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
|
||||
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
|
||||
}))}
|
||||
/>
|
||||
{/* Exchange Registration Link */}
|
||||
{formData.exchange_id && (() => {
|
||||
// Find the selected exchange to get its type
|
||||
@@ -323,22 +320,20 @@ export function TraderConfigModal({
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('useStrategy', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.strategy_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('strategy_id', e.target.value)
|
||||
onChange={(val) =>
|
||||
handleInputChange('strategy_id', val)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
<option value="">{t('noStrategyManual', language)}</option>
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.name}
|
||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={[
|
||||
{ value: '', label: t('noStrategyManual', language) },
|
||||
...strategies.map((strategy) => ({
|
||||
value: strategy.id,
|
||||
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{strategies.length === 0 && (
|
||||
<p className="text-xs text-[#848E9C] mt-2">
|
||||
{t('noStrategyHint', language)}
|
||||
|
||||
99
web/src/components/ui/select.tsx
Normal file
99
web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface NofxSelectProps {
|
||||
value: string | number
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function NofxSelect({ value, onChange, options, disabled, className, style }: NofxSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
const selected = options.find(o => String(o.value) === String(value))
|
||||
|
||||
const updatePos = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePos()
|
||||
const handleClose = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (dropdownRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const handleScroll = () => setOpen(false)
|
||||
document.addEventListener('mousedown', handleClose)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClose)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open, updatePos])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className={cn('relative', className)}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-1.5 w-full h-full cursor-pointer',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) setOpen(!open)
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? String(value)}</span>
|
||||
<ChevronDown className={cn('w-3 h-3 shrink-0 opacity-50 transition-transform', open && 'rotate-180')} />
|
||||
</div>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[9999] rounded border border-[#2B3139] bg-[#0B0E11] shadow-xl shadow-black/50 max-h-60 overflow-y-auto"
|
||||
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm cursor-pointer transition-colors whitespace-nowrap',
|
||||
String(opt.value) === String(value)
|
||||
? 'bg-[#F0B90B]/10 text-[#F0B90B]'
|
||||
: 'text-[#EAECEF] hover:bg-[#1E2329]',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(String(opt.value))
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user