Files
trumpsignal-frontend/components/signals/SystemControl.tsx
T
k d01adc4790 style(dashboard): unify Macro composite into one card + dark-mode fix
for Performance accent

User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.

  - JSX: wrap the three pieces in <div className="overview-macro-card">
  - CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
  - Solid neutral-gray filled needle (was a hollow ring — looked like a
    placeholder); tone-coloured background + white inner ring + double
    shadow so it stands out on any gradient position
  - Removed the .overview-score-fill overlay — the gradient already
    encodes the spectrum; layering an opaque fill obscured it near 0
  - Thinner track (14px vs 22px), tighter scale labels, smaller pill
  - Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
    a quick anchor of what they're looking at + freshness

Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.

Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:25:59 +08:00

336 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import Link from 'next/link'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* ONE control: the master Auto-Trade switch.
*
* OFF (default, safe) — signals are still scanned & shown in the feed,
* but NO trade is opened. Pure monitoring.
* ON — a qualifying signal auto-opens a trade (full
* System-2 risk: dynamic leverage, staged de-risk,
* ratchet, peak-trail). De-risk / stop-loss always
* run on open positions regardless — never toggleable.
*
* Replaces the old scanner-toggle / timed manual-window / schedule / paper
* trio. Circuit breaker is read-only; turning Auto-Trade ON acknowledges +
* clears a tripped breaker. Per-trade pyramiding is the "Grow" switch on
* each open position (Trades page), not here.
*/
const SYS = {
trump: { idx: '①', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
btc: { idx: '②', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
} as const
const ROW: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, padding: '8px 0', flexWrap: 'wrap',
}
const LABEL: React.CSSProperties = { fontSize: 13, color: 'var(--ink-2)' }
export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const s = SYS[system]
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [pub, setPub] = useState<UserPublic | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
useEffect(() => { setMounted(true) }, [])
const refresh = useCallback(async () => {
if (!address) { setPub(null); return }
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
setPub(p)
}, [address])
useEffect(() => {
refresh()
const poll = setInterval(refresh, 30_000)
return () => clearInterval(poll)
}, [refresh])
const subscribed = !!pub?.active
const paper = !!pub?.paper_mode
const autoOn = !!pub?.auto_trade
const cbTripped = !!pub?.circuit_breaker_tripped_at &&
(Date.now() - new Date(pub!.circuit_breaker_tripped_at!).getTime()) < 24 * 3600 * 1000
async function flipAuto(on: boolean) {
if (busy) return
if (!address) { setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).'); return }
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
if (autoOn === on) return
// Show pre-signing confirmation sheet before triggering MetaMask
const confirmed = await confirmSign(on
? {
label: paper
? (isZh ? '开启 Auto-Trade(模拟模式)' : 'Enable Auto-Trade (paper mode)')
: (isZh ? '开启 Auto-Trade(真实资金)' : 'Enable Auto-Trade (live capital)'),
description: paper
? (isZh
? '机器人将在信号触发时自动开仓(Paper 模式,无真实资金)。钱包签名仅用于身份验证。'
: 'The bot will auto-open positions when a qualifying signal fires in paper mode. No real capital is used. The wallet signature is only for identity verification.')
: (isZh
? '机器人将在 Hyperliquid 上用真实资金自动开仓。止损与分级减仓始终生效保护仓位。'
: 'The bot will auto-open real positions on Hyperliquid. Stop-loss and staged de-risking remain active to protect the position.'),
danger: !paper,
}
: {
label: isZh ? '关闭 Auto-Trade' : 'Disable Auto-Trade',
description: isZh
? '停止自动开仓。已有持仓的止损与减仓逻辑仍会继续运行。'
: 'Stop opening new trades automatically. Risk controls on existing positions will keep running.',
danger: false,
}
)
if (!confirmed) return
setErr(''); setBusy(true)
try {
const env = await signRequest({
action: 'set_auto_trade', wallet: address,
body: { enabled: on }, signMessageAsync,
})
const r = await setAutoTrade(env, on)
setPub(p => p ? {
...p,
auto_trade: r.auto_trade,
...(r.circuit_breaker_cleared
? { circuit_breaker_tripped_at: null, circuit_breaker_reason: null }
: {}),
} : p)
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? '已取消' : 'Cancelled')
} else {
setErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 110)
|| (isZh ? 'Auto-Trade 切换失败' : 'Auto-Trade toggle failed'))
}
} finally { setBusy(false) }
}
// SSR / pre-hydration: render nothing to avoid hydration mismatch with
// wagmi state that resolves client-side.
if (!mounted) return null
const settingsHref = `/${locale}/settings#${system === 'trump' ? 'trump-settings-panel' : 'btc-settings-panel'}`
const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes'
// Wallet not connected: a previous version showed a giant full-width
// "Connect a wallet..." card here, which was redundant with the navbar's
// Connect button. The opposite extreme — rendering null — silently hid
// the existence of Auto-Trade for unconnected users. Compromise: a SLIM
// inline strip that simply announces the feature without pushing the
// page around. Once connected, the full control panel renders below.
if (!isConnected) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap',
padding: '10px 14px', marginBottom: 16,
background: 'var(--bg-sunk)',
border: '1px dashed var(--line)',
borderRadius: 8,
fontSize: 12, color: 'var(--ink-3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--ink-4)', flexShrink: 0,
}} />
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
{s.name} Auto-Trade
</span>
<span>·</span>
<span style={{ color: 'var(--ink-4)' }}>
OFF connect wallet (top-right) to enable
</span>
</div>
<Link
href={settingsHref}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderRadius: 999,
border: '1px solid var(--line)',
background: 'var(--surface)',
fontSize: 12,
color: 'var(--ink)',
textDecoration: 'none',
fontWeight: 700,
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span aria-hidden="true"></span>
</Link>
</div>
)
}
// ── Net result — the single sentence that matters ─────────────────────────
let netOk = false
let netMsg = ''
if (!subscribed) {
netMsg = isZh
? '钱包还没订阅,请先去设置页完成订阅。'
: 'Wallet not subscribed — subscribe on the Settings page first.'
} else if (cbTripped) {
netMsg = isZh
? `熔断器已触发(${pub?.circuit_breaker_reason || '风险限制'})。重新打开 Auto-Trade 才会确认并恢复。`
: `Circuit breaker tripped (${pub?.circuit_breaker_reason || 'risk limit'}). Turn Auto-Trade ON to acknowledge & resume.`
} else if (!autoOn) {
netMsg = isZh
? 'Auto-Trade 当前为关闭(全局): Trump 和 BTC 信号仍会显示,但不会自动开仓。'
: 'Auto-Trade is OFF (global) — Trump AND Macro Vibes signals are shown in the feed but NOT traded.'
} else {
netOk = true
netMsg = isZh
? `Auto-Trade 已开启(全局,覆盖 Trump 和 BTC): 下一条合格信号会自动开出${paper ? '模拟' : '真实'}仓位。`
: `Auto-Trade ON (global, both Trump & BTC) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying signal.`
}
const Pill = ({ active, onClick, children, tone }: {
active: boolean; onClick: () => void; children: React.ReactNode
tone: 'green' | 'idle'
}) => {
const bg = active ? (tone === 'green' ? 'var(--up, #16a34a)' : 'var(--ink-3)') : 'transparent'
return (
<button onClick={onClick} disabled={busy}
style={{
padding: '8px 20px', fontSize: 13, fontWeight: 800, borderRadius: 8,
cursor: busy ? 'wait' : 'pointer',
border: active ? `1px solid ${bg}` : '1px solid var(--line)',
background: active ? bg : 'transparent',
color: active ? '#fff' : 'var(--ink-3)',
}}>{children}</button>
)
}
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.idx} {s.name} {isZh ? '控制面板' : '— control'}
</div>
</div>
{/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}>
<div style={ROW}>
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 全局(Trump + BTC' : '· GLOBAL (Trump + BTC)'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
{isZh ? (
<>
<strong></strong>
Trump BTC
</>
) : (
<>
<strong>One switch for both systems.</strong> OFF: signals scanned
&amp; shown in the feed, nothing traded. ON: a qualifying Trump OR
Macro Vibes signal auto-opens a trade. Stop-loss / staged de-risk
always protect open positions either way.
</>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill>
</div>
</div>
{/* Read-only status */}
<div style={{ ...ROW, borderTop: '1px solid var(--line)', marginTop: 8, paddingTop: 12 }}>
<span style={LABEL}>{isZh ? '执行模式' : 'Execution mode'}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: paper ? '#f59e0b' : 'var(--up)' }}>
{paper ? (isZh ? '📝 模拟' : '📝 PAPER') : (isZh ? '💰 真实资金' : '💰 LIVE')}
<span style={{ color: 'var(--ink-4)', fontWeight: 400, marginLeft: 8 }}>
{isZh ? '· 订阅时确定' : '· set when subscribing'}
</span>
</span>
</div>
<div style={ROW}>
<span style={LABEL}>{isZh ? '熔断器' : 'Circuit breaker'}</span>
<span style={{ fontSize: 12, fontWeight: 700,
color: cbTripped ? 'var(--down)' : 'var(--up)' }}>
{cbTripped
? (isZh ? `🚨 已触发(${pub?.circuit_breaker_reason}` : `🚨 tripped (${pub?.circuit_breaker_reason})`)
: (isZh ? '✓ 正常' : '✓ clear')}
</span>
</div>
{err && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 10 }}> {err}</div>
)}
</div>
{/* Net result */}
<div style={{
padding: '14px 16px',
background: netOk ? 'var(--up-soft)' : 'var(--down-soft, rgba(220,38,38,.08))',
borderTop: '1px solid var(--line)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 4 }}>
{isZh ? '⟹ 当前结果' : '⟹ Net result'}
</div>
<div style={{ fontSize: 14, fontWeight: 700,
color: netOk ? 'var(--up)' : 'var(--down)' }}>
{netOk ? '✅ ' : '⛔ '}{netMsg}
</div>
<div style={{ marginTop: 8 }}>
<Link
href={settingsHref}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderRadius: 999,
border: '1px solid var(--line)',
background: 'var(--surface)',
fontSize: 12,
color: 'var(--ink)',
textDecoration: 'none',
fontWeight: 700,
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span aria-hidden="true"></span>
</Link>
</div>
</div>
</div>
)
}