fix: improve frontend UX and fix OKX close position

Frontend improvements:
- Replace window.location.reload() with SWR mutate() for data refresh
- Replace native alert/confirm with toast notifications (confirmToast, notify)
- Add loading skeletons to AITradersPage and EquityChart
- Fix flash of empty state during initial load
OKX fixes:
- Fix proxy issue in Docker by using explicit no-proxy function
- Fix CloseShort sz parameter error - ensure quantity is always positive
- Fix GetPositions to return absolute value for positionAmt
This commit is contained in:
tinkle-community
2025-12-06 19:57:13 +08:00
parent a77c54dbef
commit 7bd5ca0a55
8 changed files with 132 additions and 30 deletions

BIN
img_1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
neturl "net/url"
"nofx/logger"
"strconv"
"strings"
@@ -95,11 +96,17 @@ func genOkxClOrdID() string {
return orderID
}
// noProxyFunc 返回一个始终返回 nil 的代理函数,用于禁用代理
func noProxyFunc(req *http.Request) (*neturl.URL, error) {
return nil, nil
}
// NewOKXTrader 创建OKX交易器
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
// 创建禁用代理的 HTTP 客户端
// 创建完全禁用代理的 HTTP 客户端
// 这对于 Docker 容器环境很重要,因为容器可能继承宿主机的代理环境变量
transport := &http.Transport{
Proxy: nil, // 显式禁用代理
Proxy: noProxyFunc,
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
@@ -341,11 +348,14 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
// 转换symbol格式
symbol := t.convertSymbolBack(pos.InstId)
// 确定方向
// 确定方向,并确保 posAmt 是正数
side := "long"
if pos.PosSide == "short" || posAmt < 0 {
if pos.PosSide == "short" {
side = "short"
posAmt = -posAmt // 取绝对值
}
// OKX 空仓的 pos 是负数,需要取绝对值
if posAmt < 0 {
posAmt = -posAmt
}
posMap := map[string]interface{}{
@@ -726,9 +736,13 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
if err != nil {
return nil, err
}
logger.Infof("🔍 OKX CloseShort 查找持仓: symbol=%s, 当前持仓数=%d", symbol, len(positions))
for _, pos := range positions {
logger.Infof("🔍 OKX 持仓: symbol=%v, side=%v, positionAmt=%v",
pos["symbol"], pos["side"], pos["positionAmt"])
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64) // 这已经是张数
quantity = pos["positionAmt"].(float64)
logger.Infof("🔍 OKX 找到空仓: quantity=%f", quantity)
break
}
}
@@ -737,12 +751,20 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
}
}
// 确保 quantity 是正数OKX sz 参数必须为正)
if quantity < 0 {
quantity = -quantity
}
// 获取合约信息用于格式化张数
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("获取合约信息失败: %w", err)
}
logger.Infof("🔍 OKX 合约信息: instId=%s, lotSz=%f, minSz=%f, ctVal=%f",
inst.InstID, inst.LotSz, inst.MinSz, inst.CtVal)
// quantity 已经是张数,直接格式化
szStr := t.formatSize(quantity, inst)

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import useSWR from 'swr'
import useSWR, { mutate } from 'swr'
import { api } from './lib/api'
import { ChartTabs } from './components/ChartTabs'
import { AITradersPage } from './components/AITradersPage'
@@ -15,6 +15,7 @@ import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/ConfirmDialog'
import { t, type Language } from './i18n/translations'
import { confirmToast, notify } from './lib/notify'
import { useSystemConfig } from './hooks/useSystemConfig'
import { DecisionCard } from './components/DecisionCard'
import { BacktestPage } from './components/BacktestPage'
@@ -535,17 +536,26 @@ function TraderDetailsPage({
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
if (!confirm(confirmMsg)) return
const confirmed = await confirmToast(confirmMsg, {
title: language === 'zh' ? '确认平仓' : 'Confirm Close',
okText: language === 'zh' ? '确认' : 'Confirm',
cancelText: language === 'zh' ? '取消' : 'Cancel',
})
if (!confirmed) return
setClosingPosition(symbol)
try {
await api.closePosition(selectedTraderId, symbol, side)
const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully'
alert(successMsg)
window.location.reload()
notify.success(language === 'zh' ? '平仓成功' : 'Position closed successfully')
// 使用 SWR mutate 刷新数据而非重新加载页面
await Promise.all([
mutate(`positions-${selectedTraderId}`),
mutate(`account-${selectedTraderId}`),
])
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
alert(errorMsg)
notify.error(errorMsg)
} finally {
setClosingPosition(null)
}

View File

@@ -87,7 +87,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
oiTopUrl: '',
})
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
@@ -1047,7 +1047,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</h2>
</div>
{traders && traders.length > 0 ? (
{isTradersLoading ? (
/* Loading Skeleton */
<div className="space-y-3 md:space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded gap-3 md:gap-4 animate-pulse"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-full skeleton"></div>
<div className="min-w-0 space-y-2">
<div className="skeleton h-5 w-32"></div>
<div className="skeleton h-3 w-24"></div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-8 w-20"></div>
</div>
</div>
))}
</div>
) : traders && traders.length > 0 ? (
<div className="space-y-3 md:space-y-4">
{traders.map((trader) => (
<div

View File

@@ -12,6 +12,7 @@ import {
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { confirmToast } from '../lib/notify'
import { DecisionCard } from './DecisionCard'
import type {
BacktestStatusPayload,
@@ -308,12 +309,17 @@ export function BacktestPage() {
const handleDeleteRun = async () => {
if (!selectedRunId) return
if (
typeof window !== 'undefined' &&
!window.confirm(tr('toasts.confirmDelete', { id: selectedRunId }))
) {
return
}
const confirmed = await confirmToast(
tr('toasts.confirmDelete', { id: selectedRunId }),
{
title: language === 'zh' ? '确认删除' : 'Confirm Delete',
okText: language === 'zh' ? '删除' : 'Delete',
cancelText: language === 'zh' ? '取消' : 'Cancel',
}
)
if (!confirmed) return
try {
await api.deleteBacktestRun(selectedRunId)
setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' })

View File

@@ -41,7 +41,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
const { user, token } = useAuth()
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR<EquityPoint[]>(
const { data: history, error, isLoading } = useSWR<EquityPoint[]>(
user && token && traderId ? `equity-history-${traderId}` : null,
() => api.getEquityHistory(traderId),
{
@@ -61,6 +61,22 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
}
)
// Loading state - show skeleton
if (isLoading) {
return (
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
{!embedded && (
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
{t('accountEquityCurve', language)}
</h3>
)}
<div className="animate-pulse">
<div className="skeleton h-64 w-full rounded"></div>
</div>
</div>
)
}
if (error) {
return (
<div className={embedded ? 'p-6' : 'binance-card p-6'}>

View File

@@ -28,6 +28,7 @@ import {
Send,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify'
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
@@ -175,16 +176,30 @@ export function StrategyStudioPage() {
// Delete strategy
const handleDeleteStrategy = async (id: string) => {
if (!token || !confirm(language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?')) return
if (!token) return
const confirmed = await confirmToast(
language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?',
{
title: language === 'zh' ? '确认删除' : 'Confirm Delete',
okText: language === 'zh' ? '删除' : 'Delete',
cancelText: language === 'zh' ? '取消' : 'Cancel',
}
)
if (!confirmed) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to delete strategy')
notify.success(language === 'zh' ? '策略已删除' : 'Strategy deleted')
await fetchStrategies()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
setError(errorMsg)
notify.error(errorMsg)
}
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import useSWR from 'swr'
import useSWR, { mutate } from 'swr'
import { api } from '../lib/api'
import { ChartTabs } from '../components/ChartTabs'
import { useLanguage } from '../contexts/LanguageContext'
@@ -22,6 +22,7 @@ import {
Loader2,
} from 'lucide-react'
import { stripLeadingIcons } from '../lib/text'
import { confirmToast, notify } from '../lib/notify'
import type {
SystemStatus,
AccountInfo,
@@ -88,18 +89,26 @@ export default function TraderDashboard() {
? `确定要平仓 ${symbol} ${side === 'LONG' ? '多仓' : '空仓'} 吗?`
: `Are you sure you want to close ${symbol} ${side === 'LONG' ? 'LONG' : 'SHORT'} position?`
if (!confirm(confirmMsg)) return
const confirmed = await confirmToast(confirmMsg, {
title: language === 'zh' ? '确认平仓' : 'Confirm Close',
okText: language === 'zh' ? '确认' : 'Confirm',
cancelText: language === 'zh' ? '取消' : 'Cancel',
})
if (!confirmed) return
setClosingPosition(symbol)
try {
await api.closePosition(selectedTraderId, symbol, side)
const successMsg = language === 'zh' ? '平仓成功' : 'Position closed successfully'
alert(successMsg)
// 刷新持仓数据
window.location.reload()
notify.success(language === 'zh' ? '平仓成功' : 'Position closed successfully')
// 使用 SWR mutate 刷新数据而非重新加载页面
await Promise.all([
mutate(`positions-${selectedTraderId}`),
mutate(`account-${selectedTraderId}`),
])
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : (language === 'zh' ? '平仓失败' : 'Failed to close position')
alert(errorMsg)
notify.error(errorMsg)
} finally {
setClosingPosition(null)
}