mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 05:51:01 +08:00
feat: improve strategy studio and fix trader deletion bug
- Add strategy export/import functionality to Strategy Studio - Fix trader deletion not removing from memory (competition page ghost data) - Simplify TraderConfigViewModal: remove unused fields, show strategy name - Improve quant data formatting: OI/Netflow multi-timeframe display - Add configurable OI/Netflow toggles in indicator settings - Clean up unused frontend components and dead code
This commit is contained in:
@@ -15,10 +15,6 @@ import { getExchangeIcon } from './ExchangeIcons'
|
||||
import { getModelIcon } from './ModelIcons'
|
||||
import { TraderConfigModal } from './TraderConfigModal'
|
||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||
import {
|
||||
TwoStageKeyModal,
|
||||
type TwoStageKeyModalResult,
|
||||
} from './TwoStageKeyModal'
|
||||
import {
|
||||
WebCryptoEnvironmentCheck,
|
||||
type WebCryptoCheckStatus,
|
||||
@@ -1603,11 +1599,6 @@ function ExchangeConfigModal({
|
||||
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
|
||||
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
||||
|
||||
// 安全输入状态
|
||||
const [secureInputTarget, setSecureInputTarget] = useState<
|
||||
null | 'hyperliquid' | 'aster' | 'lighter'
|
||||
>(null)
|
||||
|
||||
// 获取当前编辑的交易所信息
|
||||
const selectedExchange = allExchanges?.find(
|
||||
(e) => e.id === selectedExchangeId
|
||||
@@ -1705,44 +1696,6 @@ function ExchangeConfigModal({
|
||||
}
|
||||
}
|
||||
|
||||
// 安全输入处理函数
|
||||
const secureInputContextLabel =
|
||||
secureInputTarget === 'aster'
|
||||
? t('asterExchangeName', language)
|
||||
: secureInputTarget === 'hyperliquid'
|
||||
? t('hyperliquidExchangeName', language)
|
||||
: undefined
|
||||
|
||||
const handleSecureInputCancel = () => {
|
||||
setSecureInputTarget(null)
|
||||
}
|
||||
|
||||
const handleSecureInputComplete = ({
|
||||
value,
|
||||
obfuscationLog,
|
||||
}: TwoStageKeyModalResult) => {
|
||||
const trimmed = value.trim()
|
||||
if (secureInputTarget === 'hyperliquid') {
|
||||
setApiKey(trimmed)
|
||||
}
|
||||
if (secureInputTarget === 'aster') {
|
||||
setAsterPrivateKey(trimmed)
|
||||
}
|
||||
console.log('Secure input obfuscation log:', obfuscationLog)
|
||||
setSecureInputTarget(null)
|
||||
}
|
||||
|
||||
// 掩盖敏感数据显示
|
||||
const maskSecret = (secret: string) => {
|
||||
if (!secret || secret.length === 0) return ''
|
||||
if (secret.length <= 8) return '*'.repeat(secret.length)
|
||||
return (
|
||||
secret.slice(0, 4) +
|
||||
'*'.repeat(Math.max(secret.length - 8, 4)) +
|
||||
secret.slice(-4)
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedExchangeId) return
|
||||
@@ -2328,58 +2281,22 @@ function ExchangeConfigModal({
|
||||
>
|
||||
{t('hyperliquidAgentPrivateKey', language)}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={maskSecret(apiKey)}
|
||||
readOnly
|
||||
placeholder={t(
|
||||
'enterHyperliquidAgentPrivateKey',
|
||||
language
|
||||
)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSecureInputTarget('hyperliquid')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{apiKey
|
||||
? t('secureInputReenter', language)
|
||||
: t('secureInputButton', language)}
|
||||
</button>
|
||||
{apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiKey('')}
|
||||
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1B1F2B',
|
||||
color: '#848E9C',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('secureInputClear', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{apiKey && (
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('secureInputHint', language)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t(
|
||||
'enterHyperliquidAgentPrivateKey',
|
||||
language
|
||||
)}
|
||||
</div>
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
@@ -2605,15 +2522,6 @@ function ExchangeConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two Stage Key Modal */}
|
||||
<TwoStageKeyModal
|
||||
isOpen={secureInputTarget !== null}
|
||||
language={language}
|
||||
contextLabel={secureInputContextLabel}
|
||||
expectedLength={64}
|
||||
onCancel={handleSecureInputCancel}
|
||||
onComplete={handleSecureInputComplete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface CryptoFeatureCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
features: string[]
|
||||
className?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export const CryptoFeatureCard = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
CryptoFeatureCardProps
|
||||
>(({ icon, title, description, features, className, delay = 0 }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="relative h-full"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl',
|
||||
'bg-gradient-to-br from-[#000000] to-[#0A0A0A]',
|
||||
'border-[#1A1A1A] hover:border-[#F0B90B]/50',
|
||||
isHovered && 'shadow-[0_0_20px_rgba(240,185,11,0.2)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated glow border effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</motion.div>
|
||||
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 p-8 flex flex-col h-full">
|
||||
{/* Icon container */}
|
||||
<motion.div
|
||||
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
animate={{
|
||||
scale: isHovered ? 1.1 : 1,
|
||||
boxShadow: isHovered
|
||||
? '0 0 20px rgba(240, 185, 11, 0.4)'
|
||||
: '0 0 0px rgba(240, 185, 11, 0)',
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3
|
||||
className="text-2xl font-bold mb-3"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="mb-6 flex-grow leading-relaxed"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Features list */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: delay + index * 0.1 }}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.2)' }}
|
||||
>
|
||||
<Check
|
||||
className="w-3 h-3"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
|
||||
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
|
||||
@@ -1,116 +0,0 @@
|
||||
/// <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
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { TraderConfigData } from '../types'
|
||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||
|
||||
@@ -20,66 +18,20 @@ export function TraderConfigViewModal({
|
||||
onClose,
|
||||
traderData,
|
||||
}: TraderConfigViewModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen || !traderData) return null
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(fieldName)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
toast.success('已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
toast.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const CopyButton = ({
|
||||
text,
|
||||
fieldName,
|
||||
}: {
|
||||
text: string
|
||||
fieldName: string
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => copyToClipboard(text, fieldName)}
|
||||
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
|
||||
style={{
|
||||
background:
|
||||
copiedField === fieldName
|
||||
? 'rgba(14, 203, 129, 0.1)'
|
||||
: 'rgba(240, 185, 11, 0.1)',
|
||||
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
|
||||
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
|
||||
</button>
|
||||
)
|
||||
|
||||
const InfoRow = ({
|
||||
label,
|
||||
value,
|
||||
copyable = false,
|
||||
fieldName = '',
|
||||
}: {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
copyable?: boolean
|
||||
fieldName?: string
|
||||
}) => (
|
||||
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
|
||||
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
|
||||
<div className="flex items-center text-right">
|
||||
<span className="text-sm text-[#EAECEF] font-mono">
|
||||
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
|
||||
</span>
|
||||
{copyable && typeof value === 'string' && value && (
|
||||
<CopyButton text={value} fieldName={fieldName} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-[#EAECEF] font-mono text-right">
|
||||
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -134,17 +86,9 @@ export function TraderConfigViewModal({
|
||||
🤖 基础信息
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow
|
||||
label="交易员ID"
|
||||
value={traderData.trader_id || ''}
|
||||
copyable
|
||||
fieldName="trader_id"
|
||||
/>
|
||||
<InfoRow
|
||||
label="交易员名称"
|
||||
value={traderData.trader_name}
|
||||
copyable
|
||||
fieldName="trader_name"
|
||||
/>
|
||||
<InfoRow
|
||||
label="AI模型"
|
||||
@@ -158,118 +102,41 @@ export function TraderConfigViewModal({
|
||||
label="初始余额"
|
||||
value={`$${traderData.initial_balance.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Configuration */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
⚖️ 交易配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow
|
||||
label="保证金模式"
|
||||
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
|
||||
/>
|
||||
<InfoRow
|
||||
label="BTC/ETH 杠杆"
|
||||
value={`${traderData.btc_eth_leverage}x`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="山寨币杠杆"
|
||||
value={`${traderData.altcoin_leverage}x`}
|
||||
/>
|
||||
<InfoRow
|
||||
label="交易币种"
|
||||
value={traderData.trading_symbols || '使用默认币种'}
|
||||
copyable
|
||||
fieldName="trading_symbols"
|
||||
label="扫描间隔"
|
||||
value={`${traderData.scan_interval_minutes || 3} 分钟`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal Sources */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<InfoRow
|
||||
label="Coin Pool 信号"
|
||||
value={traderData.use_coin_pool}
|
||||
/>
|
||||
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] flex items-center gap-2">
|
||||
💬 交易策略提示词
|
||||
{/* Strategy Info - only show if strategy is bound */}
|
||||
{traderData.strategy_id && (
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
||||
📋 使用策略
|
||||
</h3>
|
||||
{traderData.custom_prompt && (
|
||||
<CopyButton
|
||||
text={traderData.custom_prompt}
|
||||
fieldName="custom_prompt"
|
||||
<div className="space-y-3">
|
||||
<InfoRow
|
||||
label="策略名称"
|
||||
value={traderData.strategy_name || traderData.strategy_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<InfoRow
|
||||
label="覆盖默认提示词"
|
||||
value={traderData.override_base_prompt}
|
||||
/>
|
||||
{traderData.custom_prompt ? (
|
||||
<div>
|
||||
<div className="text-sm text-[#848E9C] mb-2">
|
||||
{traderData.override_base_prompt
|
||||
? '自定义提示词'
|
||||
: '附加提示词'}
|
||||
:
|
||||
</div>
|
||||
<div
|
||||
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{traderData.custom_prompt}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm text-[#848E9C] italic p-3 rounded border"
|
||||
style={{ border: '1px solid #2B3139' }}
|
||||
>
|
||||
未设置自定义提示词,使用系统默认策略
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 p-6 border-t border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
JSON.stringify(traderData, null, 2),
|
||||
'full_config'
|
||||
)
|
||||
}
|
||||
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
|
||||
>
|
||||
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface TypewriterProps {
|
||||
lines: string[]
|
||||
typingSpeed?: number // 毫秒/字符
|
||||
lineDelay?: number // 每行结束的额外等待
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function Typewriter({
|
||||
lines,
|
||||
typingSpeed = 50,
|
||||
lineDelay = 600,
|
||||
className,
|
||||
style,
|
||||
}: TypewriterProps) {
|
||||
const [typedLines, setTypedLines] = useState<string[]>([''])
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const lineIndexRef = useRef(0)
|
||||
const charIndexRef = useRef(0)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const blinkRef = useRef<number | null>(null)
|
||||
const sanitizedLines = useMemo(
|
||||
() => lines.map((l) => String(l ?? '')),
|
||||
[lines]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
lineIndexRef.current = 0
|
||||
charIndexRef.current = 0
|
||||
setTypedLines([''])
|
||||
|
||||
function typeNext() {
|
||||
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
||||
if (charIndexRef.current < currentLine.length) {
|
||||
const ch = currentLine.charAt(charIndexRef.current)
|
||||
setTypedLines((prev) => {
|
||||
const next = [...prev]
|
||||
const lastIndex = next.length - 1
|
||||
next[lastIndex] = (next[lastIndex] ?? '') + ch
|
||||
return next
|
||||
})
|
||||
charIndexRef.current += 1
|
||||
timerRef.current = window.setTimeout(typeNext, typingSpeed)
|
||||
} else {
|
||||
// 行结束
|
||||
if (lineIndexRef.current < sanitizedLines.length - 1) {
|
||||
lineIndexRef.current += 1
|
||||
charIndexRef.current = 0
|
||||
setTypedLines((prev) => [...prev, ''])
|
||||
timerRef.current = window.setTimeout(typeNext, lineDelay)
|
||||
} else {
|
||||
// 最后一行输入完毕
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟一帧开始打字,确保状态已重置
|
||||
timerRef.current = window.setTimeout(typeNext, 0)
|
||||
|
||||
// 光标闪烁
|
||||
blinkRef.current = window.setInterval(() => {
|
||||
setShowCursor((v) => !v)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
||||
}
|
||||
}, [sanitizedLines, typingSpeed, lineDelay])
|
||||
|
||||
const displayText = useMemo(
|
||||
() => typedLines.join('\n').replace(/undefined/g, ''),
|
||||
[typedLines]
|
||||
)
|
||||
|
||||
return (
|
||||
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
|
||||
{displayText}
|
||||
<span style={{ opacity: showCursor ? 1 : 0 }}> ▍</span>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -428,6 +428,30 @@ export function IndicatorEditor({
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('symbolPlaceholder')}</p>
|
||||
|
||||
{/* OI and Netflow toggles */}
|
||||
<div className="flex gap-4 mt-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_oi !== false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, enable_quant_oi: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>OI</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_netflow !== false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>Netflow</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user