mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 15:26:55 +08:00
feat: improve user onboarding and setup UX (#1436)
* feat: add beginner onboarding and mode switching flow * chore: ignore local gh auth config * fix: restore kline fallback and align onboarding language --------- Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
This commit is contained in:
264
web/src/pages/BeginnerOnboardingPage.tsx
Normal file
264
web/src/pages/BeginnerOnboardingPage.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Copy, Eye, EyeOff, RefreshCw, Shield, Wallet, Sparkles } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { BeginnerOnboardingResponse } from '../types'
|
||||
import { setBeginnerWalletAddress } from '../lib/onboarding'
|
||||
|
||||
export function BeginnerOnboardingPage() {
|
||||
const { language } = useLanguage()
|
||||
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showPrivateKey, setShowPrivateKey] = useState(false)
|
||||
const [refreshingBalance, setRefreshingBalance] = useState(false)
|
||||
const hasRequestedRef = useRef(false)
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const loadOnboarding = async (showLoading: boolean) => {
|
||||
if (showLoading) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setRefreshingBalance(true)
|
||||
}
|
||||
|
||||
setError('')
|
||||
try {
|
||||
const result = await api.prepareBeginnerOnboarding()
|
||||
setData(result)
|
||||
setBeginnerWalletAddress(result.address)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: isZh
|
||||
? '新手钱包准备失败'
|
||||
: 'Failed to prepare beginner wallet'
|
||||
)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setRefreshingBalance(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRequestedRef.current) {
|
||||
return
|
||||
}
|
||||
hasRequestedRef.current = true
|
||||
void loadOnboarding(true)
|
||||
}, [])
|
||||
|
||||
const hints = useMemo(
|
||||
() =>
|
||||
isZh
|
||||
? [
|
||||
'这是你的专属 Base 钱包,只用于后续调用大模型。',
|
||||
'请保存私钥。丢失后无法恢复。',
|
||||
'只往这个地址充值 Base 链 USDC,不要充到别的链。',
|
||||
]
|
||||
: [
|
||||
'This dedicated Base wallet is only used to pay for model calls.',
|
||||
'Save the private key now. It cannot be recovered later.',
|
||||
'Deposit USDC on Base only. Do not send funds from another chain.',
|
||||
],
|
||||
[isZh]
|
||||
)
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(isZh ? `${label}已复制` : `${label} copied`)
|
||||
} catch {
|
||||
toast.error(isZh ? '复制失败' : 'Copy failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<div className="mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12">
|
||||
<div className="grid w-full gap-8 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-zinc-950/70 p-8 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-nofx-gold/15 text-nofx-gold">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.28em] text-nofx-gold/80">
|
||||
{isZh ? '新手保护' : 'Beginner Guard'}
|
||||
</div>
|
||||
<h1 className="mt-1 text-3xl font-bold text-white">
|
||||
{isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="max-w-xl text-sm leading-7 text-zinc-300">
|
||||
{isZh
|
||||
? '我们已经为你生成了一个专属钱包,并默认接入 Claw402 + DeepSeek。你现在只需要保存私钥,然后往这个地址充值 Base 链 USDC,后面调用大模型时会自动从这里扣费。'
|
||||
: 'We generated a dedicated wallet for you and preconfigured Claw402 + DeepSeek. Save the private key, then deposit Base USDC to this address so future model calls can be paid automatically.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{hints.map((hint) => (
|
||||
<div
|
||||
key={hint}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3"
|
||||
>
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-nofx-gold" />
|
||||
<div className="text-sm leading-6 text-zinc-300">{hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[24px] border border-sky-500/20 bg-sky-500/5 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-sky-300">
|
||||
<Wallet className="h-4 w-4" />
|
||||
<span>{isZh ? '为什么要充值?' : 'Why fund this wallet?'}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{isZh
|
||||
? '这里只负责大模型调用费用,不会自动替你充值交易所。先充少量 USDC 就够了,通常 $5-$10 可以用很久。'
|
||||
: 'This wallet only covers LLM usage costs. It does not fund your exchange automatically. A small amount of USDC is enough to get started, usually $5-$10.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-black/60 p-8 shadow-2xl backdrop-blur-xl">
|
||||
{loading ? (
|
||||
<div className="flex min-h-[420px] items-center justify-center text-sm text-zinc-400">
|
||||
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">
|
||||
{isZh ? '默认模型' : 'Default Model'}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-white">Claw402 + DeepSeek</div>
|
||||
<div className="mt-2 text-sm text-zinc-400">
|
||||
{isZh ? '按次付费,无需 API Key' : 'Pay per call, no API key needed'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white p-5 text-center">
|
||||
<div className="inline-flex rounded-2xl bg-white p-3">
|
||||
<QRCodeSVG value={data.address} size={180} level="M" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-semibold text-zinc-900">
|
||||
{isZh ? '充值地址(Base 链 USDC)' : 'Deposit Address (Base USDC)'}
|
||||
</div>
|
||||
<div className="mt-2 break-all rounded-2xl bg-zinc-100 px-3 py-3 font-mono text-xs text-zinc-700">
|
||||
{data.address}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-zinc-800"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{isZh ? '复制地址' : 'Copy address'}
|
||||
</button>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-700">
|
||||
{isZh ? '当前余额' : 'Current Balance'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-emerald-900">
|
||||
{data.balance_usdc} USDC
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-700/80">
|
||||
{isZh ? 'Base 链钱包余额' : 'Base wallet balance'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadOnboarding(false)}
|
||||
disabled={refreshingBalance}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-emerald-300 bg-white px-3 py-2 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshingBalance ? 'animate-spin' : ''}`} />
|
||||
{isZh ? '刷新余额' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-amber-500/20 bg-amber-500/8 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-200">
|
||||
{isZh ? '钱包私钥' : 'Wallet Private Key'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-amber-100/75">
|
||||
{isZh ? '请先备份,再进入下一步。' : 'Back this up before you continue.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPrivateKey((prev) => !prev)}
|
||||
className="rounded-xl border border-amber-400/20 px-3 py-2 text-amber-200 transition hover:bg-amber-400/10"
|
||||
>
|
||||
{showPrivateKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 break-all rounded-2xl bg-black/25 px-3 py-3 font-mono text-xs text-amber-50">
|
||||
{showPrivateKey ? data.private_key : '0x' + '•'.repeat(64)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-xl border border-amber-300/20 px-4 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/10"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{isZh ? '复制私钥' : 'Copy private key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/5 p-4 text-xs leading-6 text-zinc-400">
|
||||
<div>
|
||||
{data.env_saved
|
||||
? isZh
|
||||
? `已同步保存到环境文件:${data.env_path || '.env'}`
|
||||
: `Also saved to env: ${data.env_path || '.env'}`
|
||||
: isZh
|
||||
? '当前运行环境没有成功写回 .env,但产品已完成默认配置。'
|
||||
: 'The app is configured, but this runtime could not write back to .env.'}
|
||||
</div>
|
||||
{data.env_warning ? <div className="mt-2 text-amber-300">{data.env_warning}</div> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="w-full rounded-2xl bg-nofx-gold px-5 py-4 text-sm font-bold text-black transition hover:bg-yellow-400"
|
||||
>
|
||||
{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../lib/onboarding'
|
||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||
@@ -15,6 +21,7 @@ export function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -81,6 +88,26 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
if (nextMode === userMode) {
|
||||
return
|
||||
}
|
||||
|
||||
setUserMode(nextMode)
|
||||
setUserModeState(nextMode)
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
|
||||
: nextMode === 'beginner'
|
||||
? 'Switched to beginner mode'
|
||||
: 'Switched to advanced mode'
|
||||
)
|
||||
|
||||
const nextPath = getPostAuthPath(nextMode)
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
@@ -281,6 +308,66 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '使用模式' : 'Usage Mode'}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
|
||||
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
|
||||
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('beginner')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'beginner'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '更简单,优先显示钱包、充值和快速上手引导。'
|
||||
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('advanced')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'advanced'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '保持原来的配置与交易流程,不展示新手引导。'
|
||||
: 'Keeps the original configuration and trading workflow without beginner hints.'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
Globe,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { confirmToast, notify } from '../lib/notify'
|
||||
@@ -39,10 +38,8 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { t } from '../i18n/translations'
|
||||
import { NofxSelect } from '../components/ui/select'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
@@ -55,7 +52,6 @@ export function StrategyStudioPage() {
|
||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [tokenOverflow, setTokenOverflow] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
@@ -382,10 +378,6 @@ export function StrategyStudioPage() {
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
if (tokenOverflow && currentStrategyType === 'ai_trading') {
|
||||
notify.error(tr('tokenExceedWarning'))
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Always sync the config language with the current interface language
|
||||
@@ -413,17 +405,7 @@ export function StrategyStudioPage() {
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
notify.success(tr('strategySaved'))
|
||||
const savedId = selectedStrategy.id
|
||||
await fetchStrategies()
|
||||
// Stay on the strategy we just saved instead of jumping to active
|
||||
setStrategies(prev => {
|
||||
const saved = prev.find(s => s.id === savedId)
|
||||
if (saved) {
|
||||
setSelectedStrategy(saved)
|
||||
setEditingConfig(saved.config)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
@@ -659,7 +641,7 @@ export function StrategyStudioPage() {
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
|
||||
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
|
||||
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -774,24 +756,34 @@ export function StrategyStudioPage() {
|
||||
{selectedStrategy && editingConfig ? (
|
||||
<div className="p-4">
|
||||
{/* Strategy Name & Actions */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold whitespace-nowrap">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold">● {tr('unsaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!selectedStrategy.is_active && (
|
||||
<button
|
||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||
@@ -801,22 +793,10 @@ export function StrategyStudioPage() {
|
||||
{tr('activate')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && hasChanges && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfig(selectedStrategy.config)
|
||||
setHasChanges(false)
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{tr('discardChanges')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && (
|
||||
<button
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
|
||||
disabled={isSaving || !hasChanges}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||
>
|
||||
@@ -825,27 +805,8 @@ export function StrategyStudioPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={tr('addDescription')}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Estimate Bar */}
|
||||
{currentStrategyType === 'ai_trading' && (
|
||||
<div className="mb-4">
|
||||
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategy Type Selector */}
|
||||
{editingConfig && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
@@ -857,12 +818,9 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'ai_trading',
|
||||
grid_config: undefined,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
updateConfig('strategy_type', 'ai_trading')
|
||||
// Clear grid config when switching to AI trading
|
||||
updateConfig('grid_config', undefined)
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -881,12 +839,11 @@ export function StrategyStudioPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedStrategy?.is_default) {
|
||||
setEditingConfig(prev => prev ? {
|
||||
...prev,
|
||||
strategy_type: 'grid_trading',
|
||||
grid_config: prev.grid_config || defaultGridConfig,
|
||||
} : prev)
|
||||
setHasChanges(true)
|
||||
updateConfig('strategy_type', 'grid_trading')
|
||||
// Initialize grid config if not exists
|
||||
if (!editingConfig.grid_config) {
|
||||
updateConfig('grid_config', defaultGridConfig)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
@@ -977,16 +934,15 @@ export function StrategyStudioPage() {
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
@@ -1051,15 +1007,17 @@ export function StrategyStudioPage() {
|
||||
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
||||
</div>
|
||||
{aiModels.length > 0 ? (
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(val) => setSelectedModelId(val)}
|
||||
options={aiModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.name} (${model.provider})`,
|
||||
}))}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
>
|
||||
{aiModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||
{tr('noModel')}
|
||||
@@ -1067,16 +1025,15 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NofxSelect
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(val) => setSelectedVariant(val)}
|
||||
options={[
|
||||
{ value: 'balanced', label: tr('balanced') },
|
||||
{ value: 'aggressive', label: tr('aggressive') },
|
||||
{ value: 'conservative', label: tr('conservative') },
|
||||
]}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
/>
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||
|
||||
Reference in New Issue
Block a user