mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +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:
@@ -777,6 +777,9 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove trader from memory
|
||||||
|
s.traderManager.RemoveTrader(traderID)
|
||||||
|
|
||||||
logger.Infof("✓ Trader deleted: %s", traderID)
|
logger.Infof("✓ Trader deleted: %s", traderID)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,11 @@ func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) {
|
|||||||
|
|
||||||
// QuantData quantitative data structure (fund flow, position changes, price changes)
|
// QuantData quantitative data structure (fund flow, position changes, price changes)
|
||||||
type QuantData struct {
|
type QuantData struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
Netflow *NetflowData `json:"netflow,omitempty"`
|
Netflow *NetflowData `json:"netflow,omitempty"`
|
||||||
OI map[string]*OIData `json:"oi,omitempty"`
|
OI map[string]*OIData `json:"oi,omitempty"`
|
||||||
PriceChange map[string]float64 `json:"price_change,omitempty"`
|
PriceChange map[string]float64 `json:"price_change,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetflowData struct {
|
type NetflowData struct {
|
||||||
@@ -197,9 +197,9 @@ type FlowTypeData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OIData struct {
|
type OIData struct {
|
||||||
CurrentOI float64 `json:"current_oi"`
|
CurrentOI float64 `json:"current_oi"`
|
||||||
NetLong float64 `json:"net_long"`
|
NetLong float64 `json:"net_long"`
|
||||||
NetShort float64 `json:"net_short"`
|
NetShort float64 `json:"net_short"`
|
||||||
Delta map[string]*OIDeltaData `json:"delta,omitempty"`
|
Delta map[string]*OIDeltaData `json:"delta,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
|||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
var apiResp struct {
|
var apiResp struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Data *QuantData `json:"data"`
|
Data *QuantData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,84 +285,85 @@ func (e *StrategyEngine) formatQuantData(data *QuantData) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
indicators := e.config.Indicators
|
||||||
|
// If both OI and Netflow are disabled, return empty
|
||||||
|
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("📊 Quantitative Data:\n")
|
sb.WriteString("📊 Quantitative Data:\n")
|
||||||
|
|
||||||
// Price changes
|
// Price changes (API returns decimals, multiply by 100 for percentage)
|
||||||
if len(data.PriceChange) > 0 {
|
if len(data.PriceChange) > 0 {
|
||||||
sb.WriteString("Price Change: ")
|
sb.WriteString("Price Change: ")
|
||||||
timeframes := []string{"5m", "15m", "1h", "4h", "24h"}
|
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
for _, tf := range timeframes {
|
for _, tf := range timeframes {
|
||||||
if v, ok := data.PriceChange[tf]; ok {
|
if v, ok := data.PriceChange[tf]; ok {
|
||||||
parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, v))
|
parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(strings.Join(parts, " | "))
|
sb.WriteString(strings.Join(parts, " | "))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fund flow
|
// Fund flow (Netflow) - only show if enabled
|
||||||
if data.Netflow != nil {
|
if indicators.EnableQuantNetflow && data.Netflow != nil {
|
||||||
sb.WriteString("Fund Flow (USDT):\n")
|
sb.WriteString("Fund Flow (Netflow):\n")
|
||||||
|
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||||
|
|
||||||
// Institutional funds
|
// Institutional funds
|
||||||
if data.Netflow.Institution != nil {
|
if data.Netflow.Institution != nil {
|
||||||
if data.Netflow.Institution.Future != nil {
|
if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 {
|
||||||
sb.WriteString(" Institutional Futures: ")
|
sb.WriteString(" Institutional Futures:\n")
|
||||||
parts := []string{}
|
for _, tf := range timeframes {
|
||||||
for _, tf := range []string{"1h", "4h", "24h"} {
|
|
||||||
if v, ok := data.Netflow.Institution.Future[tf]; ok {
|
if v, ok := data.Netflow.Institution.Future[tf]; ok {
|
||||||
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(strings.Join(parts, " | "))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
if data.Netflow.Institution.Spot != nil {
|
if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 {
|
||||||
sb.WriteString(" Institutional Spot: ")
|
sb.WriteString(" Institutional Spot:\n")
|
||||||
parts := []string{}
|
for _, tf := range timeframes {
|
||||||
for _, tf := range []string{"1h", "4h", "24h"} {
|
|
||||||
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
|
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
|
||||||
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(strings.Join(parts, " | "))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retail funds
|
// Retail funds
|
||||||
if data.Netflow.Personal != nil {
|
if data.Netflow.Personal != nil {
|
||||||
if data.Netflow.Personal.Future != nil {
|
if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 {
|
||||||
sb.WriteString(" Retail Futures: ")
|
sb.WriteString(" Retail Futures:\n")
|
||||||
parts := []string{}
|
for _, tf := range timeframes {
|
||||||
for _, tf := range []string{"1h", "4h", "24h"} {
|
|
||||||
if v, ok := data.Netflow.Personal.Future[tf]; ok {
|
if v, ok := data.Netflow.Personal.Future[tf]; ok {
|
||||||
parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v))
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 {
|
||||||
|
sb.WriteString(" Retail Spot:\n")
|
||||||
|
for _, tf := range timeframes {
|
||||||
|
if v, ok := data.Netflow.Personal.Spot[tf]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(strings.Join(parts, " | "))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position data
|
// Open Interest (OI) - only show if enabled
|
||||||
if len(data.OI) > 0 {
|
if indicators.EnableQuantOI && len(data.OI) > 0 {
|
||||||
for exchange, oiData := range data.OI {
|
for exchange, oiData := range data.OI {
|
||||||
sb.WriteString(fmt.Sprintf("Open Interest (%s): Current %.2f | Long %.2f Short %.2f\n",
|
|
||||||
exchange, oiData.CurrentOI, oiData.NetLong, oiData.NetShort))
|
|
||||||
if len(oiData.Delta) > 0 {
|
if len(oiData.Delta) > 0 {
|
||||||
sb.WriteString(" OI Change: ")
|
sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange))
|
||||||
parts := []string{}
|
for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} {
|
||||||
for _, tf := range []string{"1h", "4h", "24h"} {
|
|
||||||
if d, ok := oiData.Delta[tf]; ok {
|
if d, ok := oiData.Delta[tf]; ok {
|
||||||
parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, d.OIDeltaPercent))
|
sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(strings.Join(parts, " | "))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -760,6 +761,26 @@ func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *ma
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatFlowValue formats flow value with M/K units
|
||||||
|
func formatFlowValue(v float64) string {
|
||||||
|
sign := ""
|
||||||
|
if v >= 0 {
|
||||||
|
sign = "+"
|
||||||
|
}
|
||||||
|
absV := v
|
||||||
|
if absV < 0 {
|
||||||
|
absV = -absV
|
||||||
|
}
|
||||||
|
if absV >= 1e9 {
|
||||||
|
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||||
|
} else if absV >= 1e6 {
|
||||||
|
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||||
|
} else if absV >= 1e3 {
|
||||||
|
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%.2f", sign, v)
|
||||||
|
}
|
||||||
|
|
||||||
// formatFloatSlice formats float slice
|
// formatFloatSlice formats float slice
|
||||||
func formatFloatSlice(values []float64) string {
|
func formatFloatSlice(values []float64) string {
|
||||||
strValues := make([]string, len(values))
|
strValues := make([]string, len(values))
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ type IndicatorConfig struct {
|
|||||||
// external data sources
|
// external data sources
|
||||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||||||
// quantitative data sources (capital flow, position changes, price changes)
|
// quantitative data sources (capital flow, position changes, price changes)
|
||||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||||||
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
||||||
|
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||||
|
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||||
}
|
}
|
||||||
|
|
||||||
// KlineConfig K-line configuration
|
// KlineConfig K-line configuration
|
||||||
@@ -216,8 +218,10 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
EMAPeriods: []int{20, 50},
|
EMAPeriods: []int{20, 50},
|
||||||
RSIPeriods: []int{7, 14},
|
RSIPeriods: []int{7, 14},
|
||||||
ATRPeriods: []int{14},
|
ATRPeriods: []int{14},
|
||||||
EnableQuantData: true,
|
EnableQuantData: true,
|
||||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
||||||
|
EnableQuantOI: true,
|
||||||
|
EnableQuantNetflow: true,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 3,
|
MaxPositions: 3,
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import { getExchangeIcon } from './ExchangeIcons'
|
|||||||
import { getModelIcon } from './ModelIcons'
|
import { getModelIcon } from './ModelIcons'
|
||||||
import { TraderConfigModal } from './TraderConfigModal'
|
import { TraderConfigModal } from './TraderConfigModal'
|
||||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||||
import {
|
|
||||||
TwoStageKeyModal,
|
|
||||||
type TwoStageKeyModalResult,
|
|
||||||
} from './TwoStageKeyModal'
|
|
||||||
import {
|
import {
|
||||||
WebCryptoEnvironmentCheck,
|
WebCryptoEnvironmentCheck,
|
||||||
type WebCryptoCheckStatus,
|
type WebCryptoCheckStatus,
|
||||||
@@ -1603,11 +1599,6 @@ function ExchangeConfigModal({
|
|||||||
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
|
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
|
||||||
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
||||||
|
|
||||||
// 安全输入状态
|
|
||||||
const [secureInputTarget, setSecureInputTarget] = useState<
|
|
||||||
null | 'hyperliquid' | 'aster' | 'lighter'
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
// 获取当前编辑的交易所信息
|
// 获取当前编辑的交易所信息
|
||||||
const selectedExchange = allExchanges?.find(
|
const selectedExchange = allExchanges?.find(
|
||||||
(e) => e.id === selectedExchangeId
|
(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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedExchangeId) return
|
if (!selectedExchangeId) return
|
||||||
@@ -2328,58 +2281,22 @@ function ExchangeConfigModal({
|
|||||||
>
|
>
|
||||||
{t('hyperliquidAgentPrivateKey', language)}
|
{t('hyperliquidAgentPrivateKey', language)}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-2">
|
<input
|
||||||
<div className="flex gap-2">
|
type="password"
|
||||||
<input
|
value={apiKey}
|
||||||
type="text"
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
value={maskSecret(apiKey)}
|
placeholder={t(
|
||||||
readOnly
|
'enterHyperliquidAgentPrivateKey',
|
||||||
placeholder={t(
|
language
|
||||||
'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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
className="w-full px-3 py-2 rounded"
|
||||||
|
style={{
|
||||||
|
background: '#0B0E11',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="text-xs mt-1"
|
className="text-xs mt-1"
|
||||||
style={{ color: '#848E9C' }}
|
style={{ color: '#848E9C' }}
|
||||||
@@ -2605,15 +2522,6 @@ function ExchangeConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two Stage Key Modal */}
|
|
||||||
<TwoStageKeyModal
|
|
||||||
isOpen={secureInputTarget !== null}
|
|
||||||
language={language}
|
|
||||||
contextLabel={secureInputContextLabel}
|
|
||||||
expectedLength={64}
|
|
||||||
onCancel={handleSecureInputCancel}
|
|
||||||
onComplete={handleSecureInputComplete}
|
|
||||||
/>
|
|
||||||
</div>
|
</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 type { TraderConfigData } from '../types'
|
||||||
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
|
||||||
|
|
||||||
@@ -20,66 +18,20 @@ export function TraderConfigViewModal({
|
|||||||
onClose,
|
onClose,
|
||||||
traderData,
|
traderData,
|
||||||
}: TraderConfigViewModalProps) {
|
}: TraderConfigViewModalProps) {
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
|
||||||
|
|
||||||
if (!isOpen || !traderData) return 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 = ({
|
const InfoRow = ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
copyable = false,
|
|
||||||
fieldName = '',
|
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value: string | number | boolean
|
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">
|
<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>
|
<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 text-right">
|
||||||
<span className="text-sm text-[#EAECEF] font-mono">
|
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
|
||||||
{typeof value === 'boolean' ? (value ? '是' : '否') : value}
|
</span>
|
||||||
</span>
|
|
||||||
{copyable && typeof value === 'string' && value && (
|
|
||||||
<CopyButton text={value} fieldName={fieldName} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,17 +86,9 @@ export function TraderConfigViewModal({
|
|||||||
🤖 基础信息
|
🤖 基础信息
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InfoRow
|
|
||||||
label="交易员ID"
|
|
||||||
value={traderData.trader_id || ''}
|
|
||||||
copyable
|
|
||||||
fieldName="trader_id"
|
|
||||||
/>
|
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="交易员名称"
|
label="交易员名称"
|
||||||
value={traderData.trader_name}
|
value={traderData.trader_name}
|
||||||
copyable
|
|
||||||
fieldName="trader_name"
|
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="AI模型"
|
label="AI模型"
|
||||||
@@ -158,118 +102,41 @@ export function TraderConfigViewModal({
|
|||||||
label="初始余额"
|
label="初始余额"
|
||||||
value={`$${traderData.initial_balance.toLocaleString()}`}
|
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
|
<InfoRow
|
||||||
label="保证金模式"
|
label="保证金模式"
|
||||||
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
|
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
label="BTC/ETH 杠杆"
|
label="扫描间隔"
|
||||||
value={`${traderData.btc_eth_leverage}x`}
|
value={`${traderData.scan_interval_minutes || 3} 分钟`}
|
||||||
/>
|
|
||||||
<InfoRow
|
|
||||||
label="山寨币杠杆"
|
|
||||||
value={`${traderData.altcoin_leverage}x`}
|
|
||||||
/>
|
|
||||||
<InfoRow
|
|
||||||
label="交易币种"
|
|
||||||
value={traderData.trading_symbols || '使用默认币种'}
|
|
||||||
copyable
|
|
||||||
fieldName="trading_symbols"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signal Sources */}
|
{/* Strategy Info - only show if strategy is bound */}
|
||||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
{traderData.strategy_id && (
|
||||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-4 flex items-center gap-2">
|
<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">
|
|
||||||
💬 交易策略提示词
|
|
||||||
</h3>
|
</h3>
|
||||||
{traderData.custom_prompt && (
|
<div className="space-y-3">
|
||||||
<CopyButton
|
<InfoRow
|
||||||
text={traderData.custom_prompt}
|
label="策略名称"
|
||||||
fieldName="custom_prompt"
|
value={traderData.strategy_name || traderData.strategy_id}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
|
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</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>
|
</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' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('symbolPlaceholder')}</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1339,8 +1339,7 @@ export const translations = {
|
|||||||
'使用代理钱包安全交易:代理钱包用于签名(餘額~0),主钱包持有资金(永不暴露私钥)',
|
'使用代理钱包安全交易:代理钱包用于签名(餘額~0),主钱包持有资金(永不暴露私钥)',
|
||||||
hyperliquidAgentPrivateKey: '代理私钥',
|
hyperliquidAgentPrivateKey: '代理私钥',
|
||||||
enterHyperliquidAgentPrivateKey: '输入代理钱包私钥',
|
enterHyperliquidAgentPrivateKey: '输入代理钱包私钥',
|
||||||
hyperliquidAgentPrivateKeyDesc:
|
hyperliquidAgentPrivateKeyDesc: '代理钱包仅有交易权限,无法提现',
|
||||||
'代理钱包私钥,用于签名交易(为了安全应保持余额接近0)',
|
|
||||||
hyperliquidMainWalletAddress: '主钱包地址',
|
hyperliquidMainWalletAddress: '主钱包地址',
|
||||||
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
||||||
hyperliquidMainWalletAddressDesc:
|
hyperliquidMainWalletAddressDesc:
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ReactNode } from 'react'
|
|
||||||
import { Outlet, Link } from 'react-router-dom'
|
|
||||||
import { Container } from '../components/Container'
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
|
||||||
|
|
||||||
interface AuthLayoutProps {
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
|
||||||
const { language, setLanguage } = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
|
||||||
{/* Simple Header with Logo and Language Selector */}
|
|
||||||
<nav
|
|
||||||
className="fixed top-0 w-full z-50"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(11, 14, 17, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container className="flex items-center justify-between h-16">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
|
||||||
<span className="text-xl font-bold" style={{ color: '#F0B90B' }}>
|
|
||||||
NOFX
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Language Selector */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setLanguage(language === 'zh' ? 'en' : 'zh')}
|
|
||||||
className="px-3 py-1.5 rounded text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language === 'zh' ? 'English' : '中文'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Content with top padding to avoid overlap with fixed header */}
|
|
||||||
<div className="pt-16">{children || <Outlet />}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { ReactNode } from 'react'
|
|
||||||
import { Outlet, useLocation } from 'react-router-dom'
|
|
||||||
import HeaderBar from '../components/HeaderBar'
|
|
||||||
import { Container } from '../components/Container'
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
|
||||||
import { t } from '../i18n/translations'
|
|
||||||
import { OFFICIAL_LINKS } from '../constants/branding'
|
|
||||||
|
|
||||||
interface MainLayoutProps {
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MainLayout({ children }: MainLayoutProps) {
|
|
||||||
const { language, setLanguage } = useLanguage()
|
|
||||||
const { user, logout } = useAuth()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
// 根据路径自动判断当前页面
|
|
||||||
const getCurrentPage = (): 'competition' | 'traders' | 'trader' | 'faq' => {
|
|
||||||
if (location.pathname === '/faq') return 'faq'
|
|
||||||
if (location.pathname === '/traders') return 'traders'
|
|
||||||
if (location.pathname === '/dashboard') return 'trader'
|
|
||||||
if (location.pathname === '/competition') return 'competition'
|
|
||||||
return 'competition' // 默认
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
<HeaderBar
|
|
||||||
isLoggedIn={!!user}
|
|
||||||
currentPage={getCurrentPage()}
|
|
||||||
language={language}
|
|
||||||
onLanguageChange={setLanguage}
|
|
||||||
user={user}
|
|
||||||
onLogout={logout}
|
|
||||||
onPageChange={() => {
|
|
||||||
// React Router handles navigation now
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<Container as="main" className="py-6 pt-24">
|
|
||||||
{children || <Outlet />}
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer
|
|
||||||
className="mt-16"
|
|
||||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
|
||||||
>
|
|
||||||
<Container
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
style={{ color: '#5E6673' }}
|
|
||||||
>
|
|
||||||
<p>{t('footerTitle', language)}</p>
|
|
||||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
|
||||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
|
||||||
{/* GitHub */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#F0B90B'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
{/* Twitter/X */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.twitter}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
Twitter
|
|
||||||
</a>
|
|
||||||
{/* Telegram */}
|
|
||||||
<a
|
|
||||||
href={OFFICIAL_LINKS.telegram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
|
||||||
style={{
|
|
||||||
background: '#1E2329',
|
|
||||||
color: '#848E9C',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#2B3139'
|
|
||||||
e.currentTarget.style.color = '#EAECEF'
|
|
||||||
e.currentTarget.style.borderColor = '#0088cc'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1E2329'
|
|
||||||
e.currentTarget.style.color = '#848E9C'
|
|
||||||
e.currentTarget.style.borderColor = '#2B3139'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
|
||||||
</svg>
|
|
||||||
Telegram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import { api } from '../lib/api'
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
|
||||||
import { useTradersConfigStore, useTradersModalStore } from '../stores'
|
|
||||||
import { useTraderActions } from '../hooks/useTraderActions'
|
|
||||||
import { TraderConfigModal } from '../components/TraderConfigModal'
|
|
||||||
import {
|
|
||||||
ModelConfigModal,
|
|
||||||
ExchangeConfigModal,
|
|
||||||
} from '../components/traders'
|
|
||||||
import { PageHeader } from '../components/traders/sections/PageHeader'
|
|
||||||
import { AIModelsSection } from '../components/traders/sections/AIModelsSection'
|
|
||||||
import { ExchangesSection } from '../components/traders/sections/ExchangesSection'
|
|
||||||
import { TradersGrid } from '../components/traders/sections/TradersGrid'
|
|
||||||
|
|
||||||
interface AITradersPageProps {
|
|
||||||
onTraderSelect?: (traderId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|
||||||
const { language } = useLanguage()
|
|
||||||
const { user, token } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
// Zustand stores
|
|
||||||
const {
|
|
||||||
allModels,
|
|
||||||
allExchanges,
|
|
||||||
supportedModels,
|
|
||||||
supportedExchanges,
|
|
||||||
configuredModels,
|
|
||||||
configuredExchanges,
|
|
||||||
loadConfigs,
|
|
||||||
setAllModels,
|
|
||||||
setAllExchanges,
|
|
||||||
} = useTradersConfigStore()
|
|
||||||
|
|
||||||
const {
|
|
||||||
showCreateModal,
|
|
||||||
showEditModal,
|
|
||||||
showModelModal,
|
|
||||||
showExchangeModal,
|
|
||||||
editingModel,
|
|
||||||
editingExchange,
|
|
||||||
editingTrader,
|
|
||||||
setShowCreateModal,
|
|
||||||
setShowEditModal,
|
|
||||||
setShowModelModal,
|
|
||||||
setShowExchangeModal,
|
|
||||||
setEditingModel,
|
|
||||||
setEditingExchange,
|
|
||||||
setEditingTrader,
|
|
||||||
} = useTradersModalStore()
|
|
||||||
|
|
||||||
// SWR for traders data
|
|
||||||
const { data: traders, mutate: mutateTraders } = useSWR(
|
|
||||||
user && token ? 'traders' : null,
|
|
||||||
api.getTraders,
|
|
||||||
{ refreshInterval: 5000 }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load configurations
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfigs(user, token)
|
|
||||||
}, [user, token, loadConfigs])
|
|
||||||
|
|
||||||
// Business logic hook
|
|
||||||
const {
|
|
||||||
isModelInUse,
|
|
||||||
isExchangeInUse,
|
|
||||||
handleCreateTrader,
|
|
||||||
handleEditTrader,
|
|
||||||
handleSaveEditTrader,
|
|
||||||
handleDeleteTrader,
|
|
||||||
handleToggleTrader,
|
|
||||||
handleAddModel,
|
|
||||||
handleAddExchange,
|
|
||||||
handleModelClick,
|
|
||||||
handleExchangeClick,
|
|
||||||
handleSaveModel,
|
|
||||||
handleDeleteModel,
|
|
||||||
handleSaveExchange,
|
|
||||||
handleDeleteExchange,
|
|
||||||
} = useTraderActions({
|
|
||||||
traders,
|
|
||||||
allModels,
|
|
||||||
allExchanges,
|
|
||||||
supportedModels,
|
|
||||||
supportedExchanges,
|
|
||||||
language,
|
|
||||||
mutateTraders,
|
|
||||||
setAllModels,
|
|
||||||
setAllExchanges,
|
|
||||||
setShowCreateModal,
|
|
||||||
setShowEditModal,
|
|
||||||
setShowModelModal,
|
|
||||||
setShowExchangeModal,
|
|
||||||
setEditingModel,
|
|
||||||
setEditingExchange,
|
|
||||||
editingTrader,
|
|
||||||
setEditingTrader,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算派生状态
|
|
||||||
const enabledModels = allModels?.filter((m) => m.enabled) || []
|
|
||||||
const enabledExchanges =
|
|
||||||
allExchanges?.filter((e) => {
|
|
||||||
if (!e.enabled) return false
|
|
||||||
if (e.id === 'aster') {
|
|
||||||
return e.asterUser?.trim() && e.asterSigner?.trim()
|
|
||||||
}
|
|
||||||
if (e.id === 'hyperliquid') {
|
|
||||||
return e.hyperliquidWalletAddr?.trim()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}) || []
|
|
||||||
|
|
||||||
// 处理交易员查看
|
|
||||||
const handleTraderSelect = (traderId: string) => {
|
|
||||||
if (onTraderSelect) {
|
|
||||||
onTraderSelect(traderId)
|
|
||||||
} else {
|
|
||||||
navigate(`/dashboard?trader=${traderId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 md:space-y-6 animate-fade-in">
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
language={language}
|
|
||||||
tradersCount={traders?.length || 0}
|
|
||||||
configuredModelsCount={configuredModels.length}
|
|
||||||
configuredExchangesCount={configuredExchanges.length}
|
|
||||||
onAddModel={handleAddModel}
|
|
||||||
onAddExchange={handleAddExchange}
|
|
||||||
onCreateTrader={() => setShowCreateModal(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Configuration Status */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
|
||||||
<AIModelsSection
|
|
||||||
language={language}
|
|
||||||
configuredModels={configuredModels}
|
|
||||||
isModelInUse={isModelInUse}
|
|
||||||
onModelClick={handleModelClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ExchangesSection
|
|
||||||
language={language}
|
|
||||||
configuredExchanges={configuredExchanges}
|
|
||||||
isExchangeInUse={isExchangeInUse}
|
|
||||||
onExchangeClick={handleExchangeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Traders Grid */}
|
|
||||||
<TradersGrid
|
|
||||||
language={language}
|
|
||||||
traders={traders}
|
|
||||||
onTraderSelect={handleTraderSelect}
|
|
||||||
onEditTrader={handleEditTrader}
|
|
||||||
onDeleteTrader={handleDeleteTrader}
|
|
||||||
onToggleTrader={handleToggleTrader}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<TraderConfigModal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
isEditMode={false}
|
|
||||||
availableModels={enabledModels}
|
|
||||||
availableExchanges={enabledExchanges}
|
|
||||||
onSave={handleCreateTrader}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TraderConfigModal
|
|
||||||
isOpen={showEditModal}
|
|
||||||
onClose={() => setShowEditModal(false)}
|
|
||||||
isEditMode={true}
|
|
||||||
traderData={editingTrader}
|
|
||||||
availableModels={enabledModels}
|
|
||||||
availableExchanges={enabledExchanges}
|
|
||||||
onSave={handleSaveEditTrader}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showModelModal && (
|
|
||||||
<ModelConfigModal
|
|
||||||
allModels={supportedModels}
|
|
||||||
configuredModels={allModels}
|
|
||||||
editingModelId={editingModel}
|
|
||||||
onSave={handleSaveModel}
|
|
||||||
onDelete={handleDeleteModel}
|
|
||||||
onClose={() => setShowModelModal(false)}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showExchangeModal && (
|
|
||||||
<ExchangeConfigModal
|
|
||||||
allExchanges={supportedExchanges}
|
|
||||||
editingExchangeId={editingExchange}
|
|
||||||
onSave={handleSaveExchange}
|
|
||||||
onDelete={handleDeleteExchange}
|
|
||||||
onClose={() => setShowExchangeModal(false)}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Code,
|
Code,
|
||||||
Send,
|
Send,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||||
import { confirmToast, notify } from '../lib/notify'
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
@@ -263,6 +265,67 @@ export function StrategyStudioPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export strategy as JSON file
|
||||||
|
const handleExportStrategy = (strategy: Strategy) => {
|
||||||
|
const exportData = {
|
||||||
|
name: strategy.name,
|
||||||
|
description: strategy.description,
|
||||||
|
config: strategy.config,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
version: '1.0',
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `strategy_${strategy.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
notify.success(language === 'zh' ? '策略已导出' : 'Strategy exported')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import strategy from JSON file
|
||||||
|
const handleImportStrategy = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file || !token) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const importData = JSON.parse(text)
|
||||||
|
|
||||||
|
// Validate imported data
|
||||||
|
if (!importData.config || !importData.name) {
|
||||||
|
throw new Error(language === 'zh' ? '无效的策略文件' : 'Invalid strategy file')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new strategy with imported config
|
||||||
|
const response = await fetch(`${API_BASE}/api/strategies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `${importData.name} (${language === 'zh' ? '导入' : 'Imported'})`,
|
||||||
|
description: importData.description || '',
|
||||||
|
config: importData.config,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to import strategy')
|
||||||
|
|
||||||
|
notify.success(language === 'zh' ? '策略已导入' : 'Strategy imported')
|
||||||
|
await fetchStrategies()
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
notify.error(errorMsg)
|
||||||
|
} finally {
|
||||||
|
// Reset file input
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save strategy
|
// Save strategy
|
||||||
const handleSaveStrategy = async () => {
|
const handleSaveStrategy = async () => {
|
||||||
if (!token || !selectedStrategy || !editingConfig) return
|
if (!token || !selectedStrategy || !editingConfig) return
|
||||||
@@ -526,13 +589,26 @@ export function StrategyStudioPage() {
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between mb-2 px-2">
|
<div className="flex items-center justify-between mb-2 px-2">
|
||||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
|
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={handleCreateStrategy}
|
{/* Import button with hidden file input */}
|
||||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer" style={{ color: '#848E9C' }} title={language === 'zh' ? '导入策略' : 'Import Strategy'}>
|
||||||
style={{ color: '#F0B90B' }}
|
<Upload className="w-4 h-4" />
|
||||||
>
|
<input
|
||||||
<Plus className="w-4 h-4" />
|
type="file"
|
||||||
</button>
|
accept=".json"
|
||||||
|
onChange={handleImportStrategy}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateStrategy}
|
||||||
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
title={language === 'zh' ? '新建策略' : 'New Strategy'}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{strategies.map((strategy) => (
|
{strategies.map((strategy) => (
|
||||||
@@ -554,22 +630,33 @@ export function StrategyStudioPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
|
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
|
||||||
{!strategy.is_default && (
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<button
|
||||||
<button
|
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
|
||||||
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
|
className="p-1 rounded hover:bg-white/10"
|
||||||
className="p-1 rounded hover:bg-white/10"
|
title={language === 'zh' ? '导出' : 'Export'}
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
|
<Download className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!strategy.is_default && (
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
|
<>
|
||||||
className="p-1 rounded hover:bg-red-500/20"
|
<button
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
|
||||||
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
|
className="p-1 rounded hover:bg-white/10"
|
||||||
</button>
|
title={language === 'zh' ? '复制' : 'Duplicate'}
|
||||||
</div>
|
>
|
||||||
)}
|
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
|
||||||
|
className="p-1 rounded hover:bg-red-500/20"
|
||||||
|
title={language === 'zh' ? '删除' : 'Delete'}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
{strategy.is_active && (
|
{strategy.is_active && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
|
||||||
import MainLayout from '../layouts/MainLayout'
|
|
||||||
import AuthLayout from '../layouts/AuthLayout'
|
|
||||||
import { LandingPage } from '../pages/LandingPage'
|
|
||||||
import { FAQPage } from '../pages/FAQPage'
|
|
||||||
import { LoginPage } from '../components/LoginPage'
|
|
||||||
import { RegisterPage } from '../components/RegisterPage'
|
|
||||||
import { ResetPasswordPage } from '../components/ResetPasswordPage'
|
|
||||||
import { CompetitionPage } from '../components/CompetitionPage'
|
|
||||||
import { AITradersPage } from '../pages/AITradersPage'
|
|
||||||
import TraderDashboard from '../pages/TraderDashboard'
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
element: <LandingPage />,
|
|
||||||
},
|
|
||||||
// Auth routes - using AuthLayout
|
|
||||||
{
|
|
||||||
element: <AuthLayout />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <LoginPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/register',
|
|
||||||
element: <RegisterPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/reset-password',
|
|
||||||
element: <ResetPasswordPage />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Main app routes - using MainLayout with nested routes
|
|
||||||
{
|
|
||||||
element: <MainLayout />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/faq',
|
|
||||||
element: <FAQPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/competition',
|
|
||||||
element: <CompetitionPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/traders',
|
|
||||||
element: <AITradersPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
element: <TraderDashboard />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '*',
|
|
||||||
element: <Navigate to="/" replace />,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -205,20 +205,21 @@ export interface TraderConfigData {
|
|||||||
trader_name: string
|
trader_name: string
|
||||||
ai_model: string
|
ai_model: string
|
||||||
exchange_id: string
|
exchange_id: string
|
||||||
strategy_id?: string // 策略ID(新版)
|
strategy_id?: string // 策略ID
|
||||||
|
strategy_name?: string // 策略名称
|
||||||
is_cross_margin: boolean
|
is_cross_margin: boolean
|
||||||
scan_interval_minutes: number
|
scan_interval_minutes: number
|
||||||
initial_balance: number
|
initial_balance: number
|
||||||
is_running: boolean
|
is_running: boolean
|
||||||
// 以下为旧版字段(向后兼容)
|
// 以下为旧版字段(向后兼容)
|
||||||
btc_eth_leverage: number
|
btc_eth_leverage?: number
|
||||||
altcoin_leverage: number
|
altcoin_leverage?: number
|
||||||
trading_symbols: string
|
trading_symbols?: string
|
||||||
custom_prompt: string
|
custom_prompt?: string
|
||||||
override_base_prompt: boolean
|
override_base_prompt?: boolean
|
||||||
system_prompt_template: string
|
system_prompt_template?: string
|
||||||
use_coin_pool: boolean
|
use_coin_pool?: boolean
|
||||||
use_oi_top: boolean
|
use_oi_top?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backtest types
|
// Backtest types
|
||||||
@@ -411,6 +412,8 @@ export interface IndicatorConfig {
|
|||||||
// 量化数据源(资金流向、持仓变化、价格变化)
|
// 量化数据源(资金流向、持仓变化、价格变化)
|
||||||
enable_quant_data?: boolean;
|
enable_quant_data?: boolean;
|
||||||
quant_data_api_url?: string;
|
quant_data_api_url?: string;
|
||||||
|
enable_quant_oi?: boolean;
|
||||||
|
enable_quant_netflow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KlineConfig {
|
export interface KlineConfig {
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
// 系统状态
|
|
||||||
export interface SystemStatus {
|
|
||||||
is_running: boolean
|
|
||||||
start_time: string
|
|
||||||
runtime_minutes: number
|
|
||||||
call_count: number
|
|
||||||
initial_balance: number
|
|
||||||
scan_interval: string
|
|
||||||
stop_until: string
|
|
||||||
last_reset_time: string
|
|
||||||
ai_provider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 账户信息
|
|
||||||
export interface AccountInfo {
|
|
||||||
total_equity: number
|
|
||||||
available_balance: number
|
|
||||||
total_pnl: number
|
|
||||||
total_pnl_pct: number
|
|
||||||
total_unrealized_pnl: number
|
|
||||||
margin_used: number
|
|
||||||
margin_used_pct: number
|
|
||||||
position_count: number
|
|
||||||
initial_balance: number
|
|
||||||
daily_pnl: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持仓信息
|
|
||||||
export interface Position {
|
|
||||||
symbol: string
|
|
||||||
side: string
|
|
||||||
entry_price: number
|
|
||||||
mark_price: number
|
|
||||||
quantity: number
|
|
||||||
leverage: number
|
|
||||||
unrealized_pnl: number
|
|
||||||
unrealized_pnl_pct: number
|
|
||||||
liquidation_price: number
|
|
||||||
margin_used: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 决策动作
|
|
||||||
export interface DecisionAction {
|
|
||||||
action: string
|
|
||||||
symbol: string
|
|
||||||
quantity: number
|
|
||||||
leverage: number
|
|
||||||
price: number
|
|
||||||
order_id: number
|
|
||||||
timestamp: string
|
|
||||||
success: boolean
|
|
||||||
error: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 决策记录
|
|
||||||
export interface DecisionRecord {
|
|
||||||
timestamp: string
|
|
||||||
cycle_number: number
|
|
||||||
input_prompt: string
|
|
||||||
cot_trace: string
|
|
||||||
decision_json: string
|
|
||||||
account_state: {
|
|
||||||
total_balance: number
|
|
||||||
available_balance: number
|
|
||||||
total_unrealized_profit: number
|
|
||||||
position_count: number
|
|
||||||
margin_used_pct: number
|
|
||||||
}
|
|
||||||
positions: Array<{
|
|
||||||
symbol: string
|
|
||||||
side: string
|
|
||||||
position_amt: number
|
|
||||||
entry_price: number
|
|
||||||
mark_price: number
|
|
||||||
unrealized_profit: number
|
|
||||||
leverage: number
|
|
||||||
liquidation_price: number
|
|
||||||
}>
|
|
||||||
candidate_coins: string[]
|
|
||||||
decisions: DecisionAction[]
|
|
||||||
execution_log: string[]
|
|
||||||
success: boolean
|
|
||||||
error_message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
export interface Statistics {
|
|
||||||
total_cycles: number
|
|
||||||
successful_cycles: number
|
|
||||||
failed_cycles: number
|
|
||||||
total_open_positions: number
|
|
||||||
total_close_positions: number
|
|
||||||
}
|
|
||||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user