mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-13 15:57:01 +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:
@@ -29,6 +29,7 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
Globe,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
@@ -38,8 +39,10 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { t } from '../i18n/translations'
|
||||
import { NofxSelect } from '../components/ui/select'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
@@ -52,6 +55,7 @@ export function StrategyStudioPage() {
|
||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [tokenOverflow, setTokenOverflow] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
@@ -378,6 +382,10 @@ export function StrategyStudioPage() {
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
if (tokenOverflow && currentStrategyType === 'ai_trading') {
|
||||
notify.error(tr('tokenExceedWarning'))
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Always sync the config language with the current interface language
|
||||
@@ -405,7 +413,17 @@ export function StrategyStudioPage() {
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
notify.success(tr('strategySaved'))
|
||||
const savedId = selectedStrategy.id
|
||||
await fetchStrategies()
|
||||
// Stay on the strategy we just saved instead of jumping to active
|
||||
setStrategies(prev => {
|
||||
const saved = prev.find(s => s.id === savedId)
|
||||
if (saved) {
|
||||
setSelectedStrategy(saved)
|
||||
setEditingConfig(saved.config)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
@@ -641,7 +659,7 @@ export function StrategyStudioPage() {
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
|
||||
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -756,34 +774,24 @@ export function StrategyStudioPage() {
|
||||
{selectedStrategy && editingConfig ? (
|
||||
<div className="p-4">
|
||||
{/* Strategy Name & Actions */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold whitespace-nowrap">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
{!selectedStrategy.is_active && (
|
||||
<button
|
||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||
@@ -793,10 +801,22 @@ export function StrategyStudioPage() {
|
||||
{tr('activate')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && hasChanges && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfig(selectedStrategy.config)
|
||||
setHasChanges(false)
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{tr('discardChanges')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && (
|
||||
<button
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={isSaving || !hasChanges}
|
||||
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||
>
|
||||
@@ -805,8 +825,27 @@ export function StrategyStudioPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Estimate Bar */}
|
||||
{currentStrategyType === 'ai_trading' && (
|
||||
<div className="mb-4">
|
||||
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategy Type Selector */}
|
||||
{editingConfig && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
@@ -818,9 +857,12 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
updateConfig('strategy_type', 'ai_trading')
|
||||
// Clear grid config when switching to AI trading
|
||||
updateConfig('grid_config', undefined)
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'ai_trading',
|
||||
grid_config: undefined,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -839,11 +881,12 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
updateConfig('strategy_type', 'grid_trading')
|
||||
// Initialize grid config if not exists
|
||||
if (!editingConfig.grid_config) {
|
||||
updateConfig('grid_config', defaultGridConfig)
|
||||
}
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'grid_trading',
|
||||
grid_config: prev.grid_config || defaultGridConfig,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -934,15 +977,16 @@ export function StrategyStudioPage() {
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
<NofxSelect
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
@@ -1007,17 +1051,15 @@ export function StrategyStudioPage() {
|
||||
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
||||
</div>
|
||||
{aiModels.length > 0 ? (
|
||||
<select
|
||||
<NofxSelect
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
onChange={(val) => setSelectedModelId(val)}
|
||||
options={aiModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.name} (${model.provider})`,
|
||||
}))}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
{aiModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||
{tr('noModel')}
|
||||
@@ -1025,15 +1067,16 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
<NofxSelect
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
/>
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { NofxSelect } from '../components/ui/select'
|
||||
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
||||
import type {
|
||||
SystemStatus,
|
||||
@@ -376,17 +377,12 @@ export function TraderDashboardPage({
|
||||
{/* Trader Selector */}
|
||||
{traders && traders.length > 0 && (
|
||||
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => onTraderSelect(e.target.value)}
|
||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
|
||||
{trader.trader_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NofxSelect
|
||||
value={selectedTraderId || ''}
|
||||
onChange={(val) => onTraderSelect(val)}
|
||||
options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))}
|
||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -484,16 +480,22 @@ export function TraderDashboardPage({
|
||||
</div>
|
||||
|
||||
{/* Debug Info */}
|
||||
{account && (
|
||||
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
||||
<span>SYSTEM_STATUS::ONLINE</span>
|
||||
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
||||
<span style={{ color: '#0ECB81' }}>SYSTEM_STATUS::ONLINE</span>
|
||||
{account ? (
|
||||
<div className="flex gap-4">
|
||||
<span>LAST_UPDATE::{lastUpdate}</span>
|
||||
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
|
||||
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
|
||||
<span>EQ::{account.total_equity?.toFixed(2)}</span>
|
||||
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
<span className="inline-block w-32 h-3 rounded bg-white/5 animate-pulse" />
|
||||
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
|
||||
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
@@ -504,6 +506,7 @@ export function TraderDashboardPage({
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) > 0}
|
||||
icon="💰"
|
||||
loading={!account}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('availableBalance', language)}
|
||||
@@ -511,6 +514,7 @@ export function TraderDashboardPage({
|
||||
unit="USDT"
|
||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||
icon="💳"
|
||||
loading={!account}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('totalPnL', language)}
|
||||
@@ -519,6 +523,7 @@ export function TraderDashboardPage({
|
||||
change={account?.total_pnl_pct || 0}
|
||||
positive={(account?.total_pnl ?? 0) >= 0}
|
||||
icon="📈"
|
||||
loading={!account}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('positions', language)}
|
||||
@@ -526,6 +531,7 @@ export function TraderDashboardPage({
|
||||
unit="ACTIVE"
|
||||
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||
icon="📊"
|
||||
loading={!account}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -671,15 +677,12 @@ export function TraderDashboardPage({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('traderDashboard.perPage', language)}:</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={positionsPageSize}
|
||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main focus:outline-none focus:border-nofx-gold/50 transition-colors"
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
onChange={(val) => setPositionsPageSize(Number(val))}
|
||||
options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{totalPositionPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -752,17 +755,12 @@ export function TraderDashboardPage({
|
||||
)}
|
||||
</div>
|
||||
{/* Limit Selector */}
|
||||
<select
|
||||
<NofxSelect
|
||||
value={decisionsLimit}
|
||||
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent focus:outline-none"
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
onChange={(val) => onDecisionsLimitChange(Number(val))}
|
||||
options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Decisions List - Scrollable */}
|
||||
@@ -818,6 +816,7 @@ function StatCard({
|
||||
positive,
|
||||
subtitle,
|
||||
icon,
|
||||
loading,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
@@ -826,6 +825,7 @@ function StatCard({
|
||||
positive?: boolean
|
||||
subtitle?: string
|
||||
icon?: string
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
||||
@@ -835,27 +835,35 @@ function StatCard({
|
||||
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||
{value}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 w-24 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-3 w-16 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||
</div>
|
||||
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||
>
|
||||
<span>{positive ? '▲' : '▼'}</span>
|
||||
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||
{value}
|
||||
</div>
|
||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
||||
{subtitle}
|
||||
</div>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||
>
|
||||
<span>{positive ? '▲' : '▼'}</span>
|
||||
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user