Files
nofx/web/src/components/strategy/CoinSourceEditor.tsx
2026-05-09 14:48:24 +08:00

434 lines
16 KiB
TypeScript

import { useState } from 'react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
import { coinSource, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
interface CoinSourceEditorProps {
config: CoinSourceConfig
onChange: (config: CoinSourceConfig) => void
disabled?: boolean
language: string
}
export function CoinSourceEditor({
config,
onChange,
disabled,
language,
}: CoinSourceEditorProps) {
const [newCoin, setNewCoin] = useState('')
const [newExcludedCoin, setNewExcludedCoin] = useState('')
const sourceTypes = [
{ value: 'static', icon: List, color: '#848E9C' },
{ value: 'ai500', icon: Database, color: '#F0B90B' },
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
] as const
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
const xyzDexAssets = new Set([
// Stocks
'TSLA', 'NVDA', 'AAPL', 'MSFT', 'META', 'AMZN', 'GOOGL', 'AMD', 'COIN', 'NFLX',
'PLTR', 'HOOD', 'INTC', 'MSTR', 'TSM', 'ORCL', 'MU', 'RIVN', 'COST', 'LLY',
'CRCL', 'SKHX', 'SNDK',
// Forex
'EUR', 'JPY',
// Commodities
'GOLD', 'SILVER',
// Index
'XYZ100',
])
const isXyzDexAsset = (symbol: string): boolean => {
const base = symbol.toUpperCase().replace(/^XYZ:/, '').replace(/USDT$|USD$|-USDC$/, '')
return xyzDexAssets.has(base)
}
const MAX_STATIC_COINS = 10
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
let formattedSymbol: string
if (isXyzDexAsset(symbol)) {
// Remove xyz: prefix (case-insensitive) and any USD suffixes
const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '')
formattedSymbol = `xyz:${base}`
} else {
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
}
if (!currentCoins.includes(formattedSymbol)) {
onChange({
...config,
static_coins: [...currentCoins, formattedSymbol],
})
}
setNewCoin('')
}
const handleRemoveCoin = (coin: string) => {
onChange({
...config,
static_coins: (config.static_coins || []).filter((c) => c !== coin),
})
}
const handleAddExcludedCoin = () => {
if (!newExcludedCoin.trim()) return
const symbol = newExcludedCoin.toUpperCase().trim()
// For xyz dex assets, use xyz: prefix without USDT
let formattedSymbol: string
if (isXyzDexAsset(symbol)) {
const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '')
formattedSymbol = `xyz:${base}`
} else {
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
}
const currentExcluded = config.excluded_coins || []
if (!currentExcluded.includes(formattedSymbol)) {
onChange({
...config,
excluded_coins: [...currentExcluded, formattedSymbol],
})
}
setNewExcludedCoin('')
}
const handleRemoveExcludedCoin = (coin: string) => {
onChange({
...config,
excluded_coins: (config.excluded_coins || []).filter((c) => c !== coin),
})
}
// NofxOS badge component
const NofxOSBadge = () => (
<span
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30"
>
NofxOS
</span>
)
return (
<div className="space-y-6">
{/* Source Type Selector */}
<div>
<label className="block text-sm font-medium mb-3 text-nofx-text">
{ts(coinSource.sourceType, language)}
</label>
<div className="grid grid-cols-4 gap-2">
{sourceTypes.map(({ value, icon: Icon, color }) => (
<button
key={value}
onClick={() =>
!disabled &&
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
}
disabled={disabled}
className={`p-4 rounded-lg border transition-all ${config.source_type === value
? 'ring-2 ring-nofx-gold bg-nofx-gold/10'
: 'hover:bg-white/5 bg-nofx-bg'
} border-nofx-gold/20`}
>
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} />
<div className="text-sm font-medium text-nofx-text">
{ts(coinSource[value as keyof typeof coinSource], language)}
</div>
<div className="text-xs mt-1 text-nofx-text-muted">
{ts(coinSource[`${value}Desc` as keyof typeof coinSource], language)}
</div>
</button>
))}
</div>
</div>
{/* Static Coins - only for static mode */}
{config.source_type === 'static' && (
<div>
<label className="block text-sm font-medium mb-3 text-nofx-text">
{ts(coinSource.staticCoins, language)}
</label>
<div className="flex flex-wrap gap-2 mb-3">
{(config.static_coins || []).map((coin) => (
<span
key={coin}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-bg-lighter text-nofx-text"
>
{coin}
{!disabled && (
<button
onClick={() => handleRemoveCoin(coin)}
className="ml-1 hover:text-red-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
{!disabled && (
<div className="flex gap-2">
<input
type="text"
value={newCoin}
onChange={(e) => setNewCoin(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()}
placeholder="BTC, ETH, SOL..."
className="flex-1 px-4 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
<button
onClick={handleAddCoin}
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors bg-nofx-gold text-black hover:bg-yellow-500"
>
<Plus className="w-4 h-4" />
{ts(coinSource.addCoin, language)}
</button>
</div>
)}
</div>
)}
{/* Excluded Coins */}
<div>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-nofx-danger" />
<label className="text-sm font-medium text-nofx-text">
{ts(coinSource.excludedCoins, language)}
</label>
</div>
<p className="text-xs mb-3 text-nofx-text-muted">
{ts(coinSource.excludedCoinsDesc, language)}
</p>
<div className="flex flex-wrap gap-2 mb-3">
{(config.excluded_coins || []).map((coin) => (
<span
key={coin}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-danger/15 text-nofx-danger"
>
{coin}
{!disabled && (
<button
onClick={() => handleRemoveExcludedCoin(coin)}
className="ml-1 hover:text-white transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{(config.excluded_coins || []).length === 0 && (
<span className="text-xs italic text-nofx-text-muted">
{ts(coinSource.excludedNone, language)}
</span>
)}
</div>
{!disabled && (
<div className="flex gap-2">
<input
type="text"
value={newExcludedCoin}
onChange={(e) => setNewExcludedCoin(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddExcludedCoin()}
placeholder="BTC, ETH, DOGE..."
className="flex-1 px-4 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
<button
onClick={handleAddExcludedCoin}
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors text-sm bg-nofx-danger text-white hover:bg-red-600"
>
<Ban className="w-4 h-4" />
{ts(coinSource.addExcludedCoin, language)}
</button>
</div>
)}
</div>
{/* AI500 Options - only for ai500 mode */}
{config.source_type === 'ai500' && (
<div
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-nofx-gold" />
<span className="text-sm font-medium text-nofx-text">
AI500 {ts(coinSource.dataSourceConfig, language)}
</span>
<NofxOSBadge />
</div>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.use_ai500}
onChange={(e) =>
!disabled && onChange({ ...config, use_ai500: e.target.checked })
}
disabled={disabled}
className="w-5 h-5 rounded accent-nofx-gold"
/>
<span className="text-nofx-text">{ts(coinSource.useAI500, language)}</span>
</label>
{config.use_ai500 && (
<div className="flex items-center gap-3 pl-8">
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.ai500Limit, language)}:
</span>
<NofxSelect
value={config.ai500_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, ai500_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
/>
</div>
)}
<p className="text-xs pl-8 text-nofx-text-muted">
{ts(coinSource.nofxosNote, language)}
</p>
</div>
</div>
)}
{/* OI Top Options - only for oi_top mode */}
{config.source_type === 'oi_top' && (
<div
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-nofx-success" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiIncreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)}
</span>
<NofxOSBadge />
</div>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.use_oi_top}
onChange={(e) =>
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
}
disabled={disabled}
className="w-5 h-5 rounded accent-nofx-success"
/>
<span className="text-nofx-text">{ts(coinSource.useOITop, language)}</span>
</label>
{config.use_oi_top && (
<div className="flex items-center gap-3 pl-8">
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.oiTopLimit, language)}:
</span>
<NofxSelect
value={config.oi_top_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
/>
</div>
)}
<p className="text-xs pl-8 text-nofx-text-muted">
{ts(coinSource.nofxosNote, language)}
</p>
</div>
</div>
)}
{/* OI Low Options - only for oi_low mode */}
{config.source_type === 'oi_low' && (
<div
className="p-4 rounded-lg bg-nofx-danger/5 border border-nofx-danger/20"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-nofx-danger" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiDecreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)}
</span>
<NofxOSBadge />
</div>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.use_oi_low}
onChange={(e) =>
!disabled && onChange({ ...config, use_oi_low: e.target.checked })
}
disabled={disabled}
className="w-5 h-5 rounded accent-red-500"
/>
<span className="text-nofx-text">{ts(coinSource.useOILow, language)}</span>
</label>
{config.use_oi_low && (
<div className="flex items-center gap-3 pl-8">
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.oiLowLimit, language)}:
</span>
<NofxSelect
value={config.oi_low_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, oi_low_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
/>
</div>
)}
<p className="text-xs pl-8 text-nofx-text-muted">
{ts(coinSource.nofxosNote, language)}
</p>
</div>
</div>
)}
</div>
)
}