done
This commit is contained in:
@@ -1,64 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrumpPost, BotTrade, BotPerformance, Candle } from '@/types'
|
||||
import { useAccount } from 'wagmi'
|
||||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getPrices } from '@/lib/api'
|
||||
import KpiRow from '@/components/dashboard/KpiRow'
|
||||
import { getPrices, getUserPublic } from '@/lib/api'
|
||||
import ChartPanel from '@/components/dashboard/ChartPanel'
|
||||
import BotPanel from '@/components/dashboard/BotPanel'
|
||||
import TradesTable from '@/components/trades/TradesTable'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo } from '@/components/dashboard/PostCards'
|
||||
|
||||
interface Props {
|
||||
initialPosts: TrumpPost[]
|
||||
initialPerformance?: BotPerformance
|
||||
initialTrades: BotTrade[]
|
||||
}
|
||||
|
||||
export default function DashboardClient({ initialPosts, initialPerformance, initialTrades }: Props) {
|
||||
const { asset, timeframe, setLivePrice } = useDashboardStore()
|
||||
// ── Inline post detail panel shown in the right rail ──────────────────────────
|
||||
function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) {
|
||||
const impact = post.price_impact
|
||||
|
||||
function fmtImpactPct(v: number | null | undefined) {
|
||||
if (v == null || isNaN(v)) return '—'
|
||||
const s = Math.abs(v).toFixed(2) + '%'
|
||||
return v >= 0 ? '+' + s : '-' + s
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
{/* header */}
|
||||
<div className="row between" style={{ marginBottom: 14 }}>
|
||||
<span className="tiny">Signal detail</span>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={onClose}
|
||||
style={{ width: 26, height: 26, borderRadius: '50%' }}
|
||||
title="Close"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* source + time */}
|
||||
<div className="row gap-s" style={{ marginBottom: 12 }}>
|
||||
<SourceIcon source={post.source} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>@realDonaldTrump</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>{timeAgo(post.published_at)} ago · {new Date(post.published_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* post text */}
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, margin: '0 0 16px', color: 'var(--ink)' }}>{post.text}</p>
|
||||
|
||||
{/* signal + sentiment */}
|
||||
<div className="row gap-s" style={{ marginBottom: 16 }}>
|
||||
<SignalPill signal={post.signal} />
|
||||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>
|
||||
{post.sentiment}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* AI confidence */}
|
||||
<div className="row between" style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 6 }}>
|
||||
<span>AI confidence</span>
|
||||
<span className="mono" style={{ color: 'var(--ink)', fontWeight: 600 }}>{post.ai_confidence}%</span>
|
||||
</div>
|
||||
<div className="confidence-bar" style={{ marginBottom: 16 }}>
|
||||
<div style={{ width: post.ai_confidence + '%' }} />
|
||||
</div>
|
||||
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>AI reasoning</div>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: 'var(--bg-sunk)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--line)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--ink-2)',
|
||||
marginBottom: 16,
|
||||
maxHeight: 120,
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{post.ai_reasoning}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Price impact grid */}
|
||||
{impact && (
|
||||
<>
|
||||
<div className="tiny" style={{ marginBottom: 10 }}>Price impact · {impact.asset}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => {
|
||||
const v = impact[key]
|
||||
const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
|
||||
return (
|
||||
<div key={key} style={{
|
||||
padding: 10,
|
||||
background: 'var(--bg-sunk)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--line)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
|
||||
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{fmtImpactPct(v)}
|
||||
</div>
|
||||
{correct != null && (
|
||||
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
|
||||
{correct ? '✓' : '✗'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Empty state for when nothing is selected ──────────────────────────────────
|
||||
function SelectHint() {
|
||||
return (
|
||||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" style={{ margin: '0 auto 12px', display: 'block', opacity: 0.4 }}>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
|
||||
Click any marker on the chart<br/>or a post below to see details
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main dashboard ─────────────────────────────────────────────────────────────
|
||||
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
|
||||
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setHlApiKeySet, isSubscribed } = useDashboardStore()
|
||||
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
|
||||
const { address, isConnected } = useAccount()
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||||
const [candles, setCandles] = useState<Candle[]>([])
|
||||
const [candlesLoading, setCandlesLoading] = useState(false)
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
return
|
||||
}
|
||||
getUserPublic(address.toLowerCase())
|
||||
.then((user) => {
|
||||
setSubscribed(user.active)
|
||||
setHlApiKeySet(user.hl_api_key_set)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet])
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 50)),
|
||||
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCandles([])
|
||||
setCandlesLoading(true)
|
||||
getPrices(asset, timeframe)
|
||||
.then(setCandles)
|
||||
.catch(() => {})
|
||||
.finally(() => setCandlesLoading(false))
|
||||
}, [asset, timeframe])
|
||||
|
||||
return (
|
||||
<div className="pt-14">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6 flex flex-col gap-4">
|
||||
{/* KPI 行 */}
|
||||
<KpiRow performance={initialPerformance} postsCount={posts.length} />
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
const recentPosts = posts.slice(0, 8)
|
||||
|
||||
{/* 主内容区:图表 + Bot 面板 */}
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
<ChartPanel posts={posts} candles={candles} />
|
||||
{candlesLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-[10px] pointer-events-none">
|
||||
<span className="text-[#555555] text-sm">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
const lastCandle = candles[candles.length - 1]
|
||||
const firstCandle = candles[0]
|
||||
const priceChange = lastCandle && firstCandle
|
||||
? ((lastCandle.close - firstCandle.open) / firstCandle.open) * 100
|
||||
: 0
|
||||
|
||||
const totalPosts = posts.length
|
||||
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
|
||||
const winRate = initialPerformance?.win_rate ?? 0
|
||||
const netPnl = initialPerformance?.net_pnl_usd ?? 0
|
||||
|
||||
return (
|
||||
<div className="page wide">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Signal monitor</h1>
|
||||
<p className="page-sub">
|
||||
{actionablePosts} actionable signals · Auto-trader {isSubscribed ? 'running' : 'standby'} · Model v4-selective
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-s">
|
||||
<span className="chip"><span className="live-dot" />Live feed</span>
|
||||
<span className="chip">{totalPosts} posts tracked</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Row */}
|
||||
<div className="kpi-row">
|
||||
<div className="kpi">
|
||||
<div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
|
||||
<div className="value">{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}</div>
|
||||
<div className="foot">
|
||||
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)}</span>
|
||||
<span>{timeframe}</span>
|
||||
</div>
|
||||
<div className="w-[280px] shrink-0">
|
||||
<BotPanel performance={initialPerformance} />
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Signals today</div>
|
||||
<div className="value">{actionablePosts}</div>
|
||||
<div className="foot"><span>Actionable posts</span></div>
|
||||
</div>
|
||||
<div className="kpi accent">
|
||||
<div className="label">30d Net P&L</div>
|
||||
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
|
||||
<div className="foot"><span>Bot performance</span></div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="label">Win rate</div>
|
||||
<div className="value">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div className="foot"><span>{initialPerformance?.total_trades ?? 0} trades</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
{/* Left: Chart + signal stream */}
|
||||
<div className="stack gap-l">
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div className="tiny">Price · {asset}</div>
|
||||
<div className="row gap-m" style={{ marginTop: 6 }}>
|
||||
<div className="hero-value mono" style={{ fontSize: 32 }}>
|
||||
{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}
|
||||
</div>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{fmtPct(priceChange)} · {timeframe}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
|
||||
<div className="asset-switch">
|
||||
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
|
||||
<span className="asset-dot btc" /> BTC
|
||||
</button>
|
||||
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
|
||||
<span className="asset-dot eth" /> ETH
|
||||
</button>
|
||||
</div>
|
||||
<div className="tf-bar">
|
||||
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
|
||||
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-wrap">
|
||||
<ChartPanel
|
||||
posts={posts}
|
||||
candles={candles}
|
||||
externalSelectedId={selectedPostId}
|
||||
onSelectPost={setSelectedPostId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chart-footnote">
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
|
||||
<div style={{ marginLeft: 'auto' }}>Click marker to see details →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent signals list */}
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>Recent signals</h2>
|
||||
<span className="hint">Showing {recentPosts.length} of {totalPosts}</span>
|
||||
</div>
|
||||
<div className="post-stream">
|
||||
{recentPosts.map(p => (
|
||||
<PostRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
selected={selectedPostId === p.id}
|
||||
onClick={() => setSelectedPostId(selectedPostId === p.id ? null : p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 交易记录 */}
|
||||
<TradesTable trades={initialTrades} />
|
||||
{/* Right rail: bot stats + post detail */}
|
||||
<div className="rail">
|
||||
<BotPanel performance={initialPerformance} />
|
||||
|
||||
{/* Post detail — shown when something is selected, else hint */}
|
||||
{selectedPost
|
||||
? <PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
|
||||
: <SelectHint />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user