d50c05b120
Frontend half of the pre-launch audit campaign: - types/index.ts + kol/KolPageClient.tsx: add missing 'reduce' KolAction (backend emits it; frontend lacked the type + color/label maps → undefined styling). Adds ACTION_COLOR/actionLabel/postActionLabel entries. - proxy/[...path]/route.ts: relay real client IP (x-forwarded-for / x-real-ip) so the backend rate limiter buckets per-user instead of per-Next-server (BUG-02). - Settings/BotConfigPanel redesign, paper-mode clarity, copy cleanup. - Assorted page/display fixes, loading states, Pagination component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
516 lines
22 KiB
TypeScript
516 lines
22 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* Open positions panel — what's currently on the book.
|
||
*
|
||
* The single most-asked question on any trading dashboard: "what do I hold
|
||
* right now?" Polls /api/positions/open + /api/positions/today every 15s.
|
||
* Compact enough to embed at the top of Dashboard AND Trades.
|
||
*
|
||
* Empty state is intentional: explicitly says "0 open" rather than hiding,
|
||
* so the user can confirm the bot isn't doing anything weird off-screen.
|
||
*/
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useLocale } from 'next-intl'
|
||
import { useAccount, useSignMessage } from 'wagmi'
|
||
import {
|
||
getOpenPositions,
|
||
getTodayStats,
|
||
manualCloseTrade,
|
||
setTradeGrow,
|
||
type OpenPosition,
|
||
type TodayStats,
|
||
} from '@/lib/api'
|
||
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||
|
||
const POLL_MS = 15_000
|
||
|
||
function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) {
|
||
if (n == null || isNaN(n)) return '—'
|
||
const sign = opts.sign === true
|
||
const abs = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
if (n < 0) return '-$' + abs
|
||
if (sign && n > 0) return '+$' + abs
|
||
return '$' + abs
|
||
}
|
||
function fmtPct(n: number | null | undefined) {
|
||
if (n == null || isNaN(n)) return '—'
|
||
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
|
||
}
|
||
function fmtHold(min: number) {
|
||
if (min < 60) return `${min}m`
|
||
const h = Math.floor(min / 60), m = min % 60
|
||
if (h < 24) return `${h}h ${m}m`
|
||
return `${Math.floor(h / 24)}d ${h % 24}h`
|
||
}
|
||
|
||
function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||
p: OpenPosition
|
||
onClose: (p: OpenPosition) => void
|
||
onToggleGrow: (p: OpenPosition) => void
|
||
growBusy: boolean
|
||
isZh: boolean
|
||
}) {
|
||
const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up'
|
||
: (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle'
|
||
const pnlColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)'
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
|
||
gap: 12, alignItems: 'center',
|
||
padding: '10px 14px',
|
||
borderTop: '1px solid var(--line)',
|
||
fontSize: 13,
|
||
}}>
|
||
{/* Asset + paper tag */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
|
||
<span style={{ fontWeight: 600 }}>{p.asset}</span>
|
||
{p.is_paper && (
|
||
<span style={{
|
||
fontSize: 9, padding: '1px 5px', borderRadius: 3,
|
||
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
|
||
}}>P</span>
|
||
)}
|
||
</div>
|
||
{/* Side */}
|
||
<span className={`side-pill ${p.side}`}>
|
||
{p.side === 'long'
|
||
? '↗ LONG'
|
||
: '↘ SHORT'}
|
||
</span>
|
||
{/* Entry → Current */}
|
||
<div className="mono" style={{ fontSize: 12 }}>
|
||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry → mark</div>
|
||
<div>
|
||
{fmtMoney(p.entry_price)}
|
||
<span style={{ color: 'var(--ink-4)' }}> → </span>
|
||
{p.current_price != null ? fmtMoney(p.current_price) : <span style={{ color: 'var(--ink-4)' }}>n/a</span>}
|
||
</div>
|
||
</div>
|
||
{/* Size + leverage (size = OPEN notional after de-risk) */}
|
||
<div className="mono" style={{ fontSize: 12 }}>
|
||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
|
||
<div>
|
||
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
|
||
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
|
||
</div>
|
||
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
|
||
<div style={{ fontSize: 9, marginTop: 2 }}>
|
||
{(p.addon_steps ?? 0) > 0 && (
|
||
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
|
||
)}
|
||
{(p.derisk_steps ?? 0) > 0 && (
|
||
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Per-trade Grow (pyramiding) switch */}
|
||
<button
|
||
onClick={() => onToggleGrow(p)}
|
||
disabled={growBusy}
|
||
title={p.grow_mode
|
||
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
|
||
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
|
||
style={{
|
||
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
|
||
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
|
||
border: '1px solid var(--line)',
|
||
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
|
||
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
|
||
}}
|
||
>
|
||
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
|
||
</button>
|
||
</div>
|
||
{/* Hold time */}
|
||
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
|
||
{fmtHold(p.hold_minutes)}
|
||
</div>
|
||
{/* Unrealized PnL */}
|
||
<div style={{ textAlign: 'right' }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: pnlColor, fontVariantNumeric: 'tabular-nums' }}>
|
||
{fmtMoney(p.unrealized_usd, { sign: true })}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
|
||
{fmtPct(p.unrealized_pct)}
|
||
</div>
|
||
{p.realized_usd != null && p.realized_usd !== 0 && (
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||
locked in {fmtMoney(p.realized_usd, { sign: true })}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
|
||
<button
|
||
onClick={() => onClose(p)}
|
||
className="btn ghost"
|
||
style={{
|
||
padding: '6px 10px', fontSize: 11, fontWeight: 600,
|
||
color: 'var(--down)',
|
||
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
|
||
}}
|
||
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
|
||
>
|
||
{isZh ? '立即平仓' : 'Close now'}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function OpenPositions() {
|
||
const locale = useLocale()
|
||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||
const { address, isConnected } = useAccount()
|
||
const { signMessageAsync } = useSignMessage()
|
||
const [mounted, setMounted] = useState(false)
|
||
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
|
||
const [today, setToday] = useState<TodayStats | null>(null)
|
||
const [err, setErr] = useState<string | null>(null)
|
||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||
// Close-confirmation modal state
|
||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||
|
||
useEffect(() => { setMounted(true) }, [])
|
||
const [closeErr, setCloseErr] = useState('')
|
||
const [growId, setGrowId] = useState<number | null>(null)
|
||
// Separate error slot for the Grow toggle so a transient toggle failure
|
||
// doesn't squat in the panel-header `err` banner (which is otherwise
|
||
// owned by the 15s poll). Auto-clears after 4s.
|
||
const [growErr, setGrowErr] = useState('')
|
||
useEffect(() => {
|
||
if (!growErr) return
|
||
const t = setTimeout(() => setGrowErr(''), 4000)
|
||
return () => clearTimeout(t)
|
||
}, [growErr])
|
||
|
||
async function toggleGrow(p: OpenPosition) {
|
||
if (!address || growId !== null) return
|
||
const next = !p.grow_mode
|
||
setGrowId(p.trade_id); setGrowErr('')
|
||
try {
|
||
const env = await signRequest({
|
||
action: 'set_trade_grow', wallet: address,
|
||
body: { trade_id: p.trade_id, enabled: next }, signMessageAsync,
|
||
})
|
||
const r = await setTradeGrow(env, p.trade_id, next)
|
||
setPositions(prev => (prev ?? []).map(x =>
|
||
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
|
||
} catch (e) {
|
||
if (isUserRejection(e)) {
|
||
setGrowErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
|
||
} else {
|
||
setGrowErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|
||
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
|
||
}
|
||
} finally { setGrowId(null) }
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!isConnected || !address) {
|
||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
|
||
// Only reuse a cached envelope here. Open positions should never trigger
|
||
// a fresh signature popup on their own while the user is browsing.
|
||
async function load() {
|
||
const env = getCachedViewEnvelope('view_positions', address!)
|
||
?? getCachedViewEnvelope('view_user', address!)
|
||
if (!env) {
|
||
if (!cancelled) {
|
||
setNeedsUnlock(true)
|
||
setErr(null)
|
||
}
|
||
return
|
||
}
|
||
try {
|
||
const [p, t] = await Promise.all([
|
||
getOpenPositions(address!, env),
|
||
getTodayStats(address!, env),
|
||
])
|
||
if (!cancelled) {
|
||
setPositions(p.positions); setToday(t); setErr(null); setNeedsUnlock(false)
|
||
}
|
||
} catch (e: unknown) {
|
||
if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error'))
|
||
}
|
||
}
|
||
|
||
void load()
|
||
const id = setInterval(() => { void load() }, POLL_MS)
|
||
return () => { cancelled = true; clearInterval(id) }
|
||
// signMessageAsync and isZh are intentionally excluded: signMessageAsync is
|
||
// never called inside this effect (load() uses cached envelopes only), and
|
||
// isZh is a compile-time constant (always false). Including either would
|
||
// restart the 15-second poll timer on every wagmi reference change.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [address, isConnected])
|
||
|
||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||
async function confirmCloseTrade() {
|
||
if (!address || !closing) return
|
||
setCloseErr(''); setCloseState('signing')
|
||
try {
|
||
const env = await signRequest({
|
||
action: 'close_trade',
|
||
wallet: address,
|
||
body: { trade_id: closing.trade_id },
|
||
signMessageAsync,
|
||
})
|
||
setCloseState('closing')
|
||
await manualCloseTrade(env, closing.trade_id)
|
||
// Optimistically drop the row from the list; the next poll will confirm.
|
||
setPositions(prev => (prev ?? []).filter(x => x.trade_id !== closing.trade_id))
|
||
setClosing(null)
|
||
setCloseState('idle')
|
||
} catch (e: unknown) {
|
||
// Distinguish a user-cancelled signature (benign, close the modal) from
|
||
// a real failure (keep modal open in 'err' state so user can retry or
|
||
// read the error).
|
||
if (isUserRejection(e)) {
|
||
setClosing(null); setCloseState('idle'); setCloseErr('')
|
||
} else {
|
||
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||
setCloseState('err')
|
||
}
|
||
}
|
||
}
|
||
|
||
// Return null both before mount (SSR) and when not connected — same output,
|
||
// no hydration mismatch.
|
||
if (!mounted || !isConnected) return null
|
||
if (needsUnlock && positions === null && today === null) {
|
||
return (
|
||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)', letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 600, marginBottom: 6 }}>
|
||
Open positions
|
||
</div>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
|
||
Sign in once to view open positions
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||
Go to the <strong>Settings page</strong> and click <em>Sign in to view your settings</em>. After signing, your open positions will appear here automatically.
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (positions === null && today === null) return null
|
||
|
||
const totalUnrealized = (positions ?? []).reduce(
|
||
(s, p) => s + (p.unrealized_usd ?? 0), 0,
|
||
)
|
||
const pnlTone = today
|
||
? (today.realized_pnl_usd > 0 ? 'up' : today.realized_pnl_usd < 0 ? 'down' : 'idle')
|
||
: 'idle'
|
||
const todayColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)'
|
||
|
||
return (
|
||
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
|
||
{/* Header strip — at-a-glance status */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '14px 18px', flexWrap: 'wrap', gap: 12,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||
Open
|
||
</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||
{today?.open_count ?? 0}
|
||
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
|
||
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
|
||
|
||
<div>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||
Unrealized
|
||
</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700,
|
||
color: totalUnrealized > 0 ? 'var(--up)' :
|
||
totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||
fontVariantNumeric: 'tabular-nums' }}>
|
||
{fmtMoney(totalUnrealized, { sign: true })}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
|
||
|
||
<div>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
|
||
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
|
||
Today's realized
|
||
</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: todayColor,
|
||
fontVariantNumeric: 'tabular-nums' }}>
|
||
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
|
||
</div>
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
|
||
</div>
|
||
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
|
||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} locked in on open trades`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{err && (
|
||
<span style={{ fontSize: 11, color: 'var(--down)' }}>● {err}</span>
|
||
)}
|
||
{growErr && !err && (
|
||
<span style={{ fontSize: 11, color: 'var(--down)' }}>● {growErr}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Position list (or empty state) */}
|
||
{positions && positions.length > 0 ? (
|
||
<div>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
|
||
gap: 12,
|
||
padding: '8px 14px',
|
||
background: 'var(--bg-sunk)',
|
||
fontSize: 10, fontWeight: 600, letterSpacing: '0.05em',
|
||
color: 'var(--ink-3)', textTransform: 'uppercase',
|
||
}}>
|
||
<span>Asset</span><span>Side</span><span>Prices</span>
|
||
<span>Size</span><span>Hold</span>
|
||
<span style={{ textAlign: 'right' }}>Unrealized</span>
|
||
<span style={{ textAlign: 'right' }}>Action</span>
|
||
</div>
|
||
{positions.map(p => (
|
||
<PositionRow
|
||
key={p.trade_id}
|
||
p={p}
|
||
onClose={setClosing}
|
||
onToggleGrow={toggleGrow}
|
||
growBusy={growId === p.trade_id}
|
||
isZh={isZh}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
borderTop: '1px solid var(--line)',
|
||
padding: '20px 18px',
|
||
fontSize: 13, color: 'var(--ink-3)', textAlign: 'center',
|
||
}}>
|
||
No open positions. When the bot opens a trade, it will show up here.
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Confirmation modal (P0.1 safety valve) ───────────────────
|
||
Two-step UX so the wallet popup isn't surprising. The user
|
||
presses "Close now", reads the impact summary, then confirms. */}
|
||
{closing && (
|
||
<div
|
||
onClick={() => {
|
||
// Backdrop dismisses in both idle (never started) and err
|
||
// (failed, user wants out) states. NOT during signing/closing —
|
||
// the wallet popup or in-flight POST shouldn't be orphaned.
|
||
if (closeState === 'idle' || closeState === 'err') {
|
||
setClosing(null); setCloseState('idle'); setCloseErr('')
|
||
}
|
||
}}
|
||
style={{
|
||
position: 'fixed', inset: 0, zIndex: 1000,
|
||
background: 'rgba(0,0,0,0.55)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: 20,
|
||
}}
|
||
>
|
||
<div
|
||
onClick={e => e.stopPropagation()}
|
||
className="card"
|
||
style={{ padding: 24, maxWidth: 440, width: '100%' }}
|
||
>
|
||
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
|
||
{isZh
|
||
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}?`
|
||
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
|
||
{closing.is_paper
|
||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
|
||
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
|
||
</div>
|
||
<div style={{
|
||
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
|
||
<span className="mono">{fmtMoney(closing.entry_price)}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
|
||
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
|
||
</div>
|
||
{closing.realized_usd != null && closing.realized_usd !== 0 && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
|
||
<span>{isZh ? '已锁定(降风险)' : 'Locked in (de-risked)'}</span>
|
||
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
|
||
<span style={{
|
||
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
|
||
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{fmtMoney(closing.unrealized_usd, { sign: true })}
|
||
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
|
||
({fmtPct(closing.unrealized_pct)})
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{closeState === 'err' && (
|
||
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={() => setClosing(null)}
|
||
disabled={closeState === 'signing' || closeState === 'closing'}
|
||
className="btn ghost"
|
||
style={{ padding: '8px 16px', fontSize: 13 }}
|
||
>
|
||
{isZh ? '取消' : 'Cancel'}
|
||
</button>
|
||
<button
|
||
onClick={confirmCloseTrade}
|
||
disabled={closeState === 'signing' || closeState === 'closing'}
|
||
className="btn"
|
||
style={{
|
||
padding: '8px 18px', fontSize: 13, fontWeight: 600,
|
||
background: 'var(--down)', color: '#fff', border: 'none',
|
||
}}
|
||
>
|
||
{closeState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
|
||
: closeState === 'closing' ? (isZh ? '平仓中…' : 'Closing…')
|
||
: (isZh ? `立即平掉 ${closing.asset}` : `Close ${closing.asset} now`)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|