330 lines
13 KiB
HTML
330 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>山寨币爆发监控</title>
|
||
<style>
|
||
* { margin:0; padding:0; box-sizing:border-box; }
|
||
body { background:#0a0a0f; color:#e0e0e0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; min-height:100vh; }
|
||
|
||
/* 顶部导航 */
|
||
.nav { background:#141420; padding:12px 16px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #2a2a3a; position:sticky; top:0; z-index:100; }
|
||
.nav h1 { font-size:18px; color:#fff; }
|
||
.nav .refresh-btn { background:none; border:1px solid #4a4a5a; color:#aaa; padding:6px 12px; border-radius:6px; font-size:13px; cursor:pointer; }
|
||
.nav .refresh-btn:active { background:#2a2a3a; }
|
||
|
||
/* 统计卡片 */
|
||
.stats-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; padding:12px 16px; }
|
||
.stat-card { background:#141420; border-radius:10px; padding:12px; text-align:center; }
|
||
.stat-card .label { font-size:11px; color:#888; margin-bottom:4px; }
|
||
.stat-card .value { font-size:20px; font-weight:bold; }
|
||
.stat-card .value.green { color:#00d4aa; }
|
||
.stat-card .value.red { color:#ff4444; }
|
||
.stat-card .value.yellow { color:#ffaa00; }
|
||
.stat-card .value.blue { color:#4488ff; }
|
||
|
||
/* Tab切换 */
|
||
.tabs { display:flex; padding:0 16px; background:#141420; border-bottom:1px solid #2a2a3a; }
|
||
.tab { padding:10px 16px; font-size:14px; color:#888; cursor:pointer; border-bottom:2px solid transparent; flex:1; text-align:center; }
|
||
.tab.active { color:#fff; border-bottom:2px solid #4488ff; }
|
||
|
||
/* 内容区 */
|
||
.content { padding:12px 16px; }
|
||
.section-title { font-size:14px; color:#888; margin:12px 0 8px; padding-left:4px; }
|
||
|
||
/* 推荐卡片 */
|
||
.rec-card { background:#1a1a28; border-radius:12px; padding:14px; margin-bottom:10px; border-left:4px solid; }
|
||
.rec-card.burst { border-left-color:#ff4444; }
|
||
.rec-card.accel { border-left-color:#ffaa00; }
|
||
.rec-card.gather { border-left-color:#4488ff; }
|
||
.rec-card.closed-profit { border-left-color:#00d4aa; }
|
||
.rec-card.closed-loss { border-left-color:#ff4444; }
|
||
.rec-card.closed-expired { border-left-color:#666; }
|
||
|
||
.rec-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||
.rec-symbol { font-size:16px; font-weight:bold; color:#fff; }
|
||
.rec-state { font-size:12px; padding:3px 8px; border-radius:4px; font-weight:bold; }
|
||
.rec-state.burst { background:#ff4444; color:#fff; }
|
||
.rec-state.accel { background:#ffaa00; color:#000; }
|
||
.rec-state.gather { background:#4488ff; color:#fff; }
|
||
.rec-state.closed-profit { background:#00d4aa; color:#000; }
|
||
.rec-state.closed-loss { background:#ff4444; color:#fff; }
|
||
.rec-state.closed-expired { background:#666; color:#fff; }
|
||
.rec-state.tp1_hit { background:#00d4aa; color:#000; }
|
||
|
||
.rec-price { font-size:14px; margin-bottom:6px; }
|
||
.rec-price .current { color:#fff; font-weight:bold; }
|
||
.rec-price .change { font-size:13px; }
|
||
.rec-price .change.pos { color:#00d4aa; }
|
||
.rec-price .change.neg { color:#ff4444; }
|
||
|
||
.rec-details { font-size:12px; color:#888; line-height:1.6; }
|
||
.rec-details span { color:#aaa; }
|
||
.rec-signals { margin-top:6px; }
|
||
.rec-signal { display:inline-block; background:#2a2a3a; padding:2px 8px; border-radius:4px; font-size:11px; color:#ccc; margin:2px 2px; }
|
||
|
||
/* 入场方案卡片 */
|
||
.entry-card { background:#1e1e2e; border-radius:8px; padding:10px; margin-top:8px; }
|
||
.entry-title { font-size:12px; color:#4488ff; margin-bottom:4px; }
|
||
.entry-row { display:flex; justify-content:space-between; font-size:12px; padding:2px 0; }
|
||
.entry-row .key { color:#888; }
|
||
.entry-row .val { color:#fff; }
|
||
.entry-row .val.green { color:#00d4aa; }
|
||
.entry-row .val.red { color:#ff4444; }
|
||
|
||
/* PnL条 */
|
||
.pnl-bar { height:6px; border-radius:3px; background:#2a2a3a; margin-top:8px; position:relative; overflow:hidden; }
|
||
.pnl-bar .fill { height:100%; border-radius:3px; }
|
||
.pnl-bar .fill.green { background:#00d4aa; }
|
||
.pnl-bar .fill.red { background:#ff4444; }
|
||
|
||
/* 空状态 */
|
||
.empty { text-align:center; padding:40px; color:#666; font-size:14px; }
|
||
|
||
/* 最后更新时间 */
|
||
.last-update { text-align:center; font-size:11px; color:#666; padding:8px 0 20px; }
|
||
|
||
/* 动画 */
|
||
@keyframes fadeIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||
.rec-card { animation: fadeIn 0.3s ease; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="nav">
|
||
<h1>🚀 山寨币监控</h1>
|
||
<button class="refresh-btn" onclick="loadData()">刷新</button>
|
||
</div>
|
||
|
||
<div class="stats-grid" id="stats-grid"></div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" data-tab="recommendations" onclick="switchTab('recommendations')">🔥 推荐</div>
|
||
<div class="tab" data-tab="candidates" onclick="switchTab('candidates')">📋 候选</div>
|
||
<div class="tab" data-tab="history" onclick="switchTab('history')">📜 历史</div>
|
||
</div>
|
||
|
||
<div class="content" id="tab-content"></div>
|
||
|
||
<div class="last-update" id="last-update"></div>
|
||
|
||
<script>
|
||
let currentTab = 'recommendations';
|
||
let dashboardData = null;
|
||
|
||
// 自动刷新
|
||
setInterval(loadData, 60000); // 1分钟刷新
|
||
|
||
async function loadData() {
|
||
try {
|
||
const resp = await fetch('/api/dashboard');
|
||
dashboardData = await resp.json();
|
||
renderStats();
|
||
renderTab();
|
||
const t = dashboardData.latest_scan_time || '--';
|
||
document.getElementById('last-update').textContent = '最后更新: ' + formatTime(t);
|
||
} catch(e) {
|
||
console.error('加载失败', e);
|
||
}
|
||
}
|
||
|
||
function renderStats() {
|
||
const s = dashboardData.stats || {};
|
||
const total = s.total_recs || 0;
|
||
const wins = s.wins || 0;
|
||
const losses = s.losses || 0;
|
||
const active = s.active || 0;
|
||
const avgPnl = s.avg_pnl || 0;
|
||
const winRate = total > 0 ? Math.round(wins/(wins+losses)*100) : 0;
|
||
|
||
document.getElementById('stats-grid').innerHTML = `
|
||
<div class="stat-card"><div class="label">活跃推荐</div><div class="value blue">${active}</div></div>
|
||
<div class="stat-card"><div class="label">胜率</div><div class="value ${winRate>=50?'green':'red'}">${wins+losses>0?winRate+'%':'--'}</div></div>
|
||
<div class="stat-card"><div class="label">平均盈亏</div><div class="value ${avgPnl>=0?'green':'red'}">${avgPnl>0?'+'+avgPnl:avgPnl}%</div></div>
|
||
<div class="stat-card"><div class="label">总推荐数</div><div class="value yellow">${total}</div></div>
|
||
`;
|
||
}
|
||
|
||
function switchTab(tab) {
|
||
currentTab = tab;
|
||
document.querySelectorAll('.tab').forEach(t => {
|
||
t.classList.toggle('active', t.dataset.tab === tab);
|
||
});
|
||
renderTab();
|
||
}
|
||
|
||
function renderTab() {
|
||
const container = document.getElementById('tab-content');
|
||
if (!dashboardData) { container.innerHTML = '<div class="empty">加载中...</div>'; return; }
|
||
|
||
if (currentTab === 'recommendations') {
|
||
renderRecommendations(container);
|
||
} else if (currentTab === 'candidates') {
|
||
renderCandidates(container);
|
||
} else if (currentTab === 'history') {
|
||
renderHistory(container);
|
||
}
|
||
}
|
||
|
||
function renderRecommendations(container) {
|
||
const active = dashboardData.active_recommendations || [];
|
||
const closed = dashboardData.closed_recommendations || [];
|
||
|
||
if (active.length === 0 && closed.length === 0) {
|
||
container.innerHTML = '<div class="empty">暂无推荐 — 等待爆发信号确认</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
if (active.length > 0) {
|
||
html += '<div class="section-title">🔥 活跃推荐</div>';
|
||
active.forEach(r => { html += renderRecCard(r); });
|
||
}
|
||
if (closed.length > 0) {
|
||
html += '<div class="section-title">📜 已关闭</div>';
|
||
closed.forEach(r => { html += renderRecCard(r); });
|
||
}
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderCandidates(container) {
|
||
const cands = dashboardData.latest_candidates || [];
|
||
if (cands.length === 0) {
|
||
container.innerHTML = '<div class="empty">暂无候选 — 等待下次筛选</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="section-title">最近筛选结果 (' + formatTime(dashboardData.latest_scan_time) + ')</div>';
|
||
cands.forEach(c => {
|
||
const stateClass = c.state === '加速' ? 'accel' : 'gather';
|
||
const stateTag = c.state === '加速' ? '🔥🔥加速' : '🔥蓄力';
|
||
const tagClass = c.state === '加速' ? 'accel' : 'gather';
|
||
const signals = (c.signals||'').split(',').filter(Boolean);
|
||
const isMeme = c.is_meme ? ' 🎮MEME' : '';
|
||
const change24h = c.change_24h || 0;
|
||
const changeClass = change24h >= 0 ? 'pos' : 'neg';
|
||
const changeSign = change24h >= 0 ? '+' : '';
|
||
|
||
html += `<div class="rec-card ${stateClass}">
|
||
<div class="rec-header">
|
||
<div class="rec-symbol">${c.symbol.replace('/USDT','')}${isMeme}</div>
|
||
<div class="rec-state ${tagClass}">${stateTag} 评分${c.score}</div>
|
||
</div>
|
||
<div class="rec-price">
|
||
<span class="current">$${(c.price_at_scan||0).toFixed(4)}</span>
|
||
<span class="change ${changeClass}">${changeSign}${change24h.toFixed(1)}%/24h</span>
|
||
</div>
|
||
<div class="rec-details">
|
||
${c.sector?'<span>板块:'+c.sector+'</span>':''}
|
||
${c.leader_status?'<span>'+c.leader_status+'</span>':''}
|
||
</div>
|
||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||
</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderHistory(container) {
|
||
const scans = dashboardData.scan_stats || [];
|
||
if (scans.length === 0) {
|
||
container.innerHTML = '<div class="empty">暂无筛选历史</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="section-title">筛选历史 (最近24h)</div>';
|
||
scans.forEach(s => {
|
||
html += `<div class="rec-card gather" style="border-left-color:#4488ff">
|
||
<div class="rec-header">
|
||
<div class="rec-symbol" style="font-size:14px">${formatTime(s.scan_time)}</div>
|
||
<div class="rec-state gather">${s.count}个候选</div>
|
||
</div>
|
||
<div class="rec-details">
|
||
<span>加速:${s.accelerating||0}</span> | <span>蓄力:${s.gathering||0}</span>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderRecCard(r) {
|
||
const status = r.status || 'active';
|
||
let cardClass = 'gather';
|
||
let stateTag = '🔥蓄力';
|
||
let tagClass = 'gather';
|
||
|
||
if (status === 'active') { cardClass = 'accel'; stateTag = '🔥🔥加速'; tagClass = 'accel'; }
|
||
else if (status === 'tp1_hit') { cardClass = 'burst'; stateTag = '✅TP1触发'; tagClass = 'tp1_hit'; }
|
||
else if (status === 'closed_profit') { cardClass = 'closed-profit'; stateTag = '✅盈利'; tagClass = 'closed-profit'; }
|
||
else if (status === 'closed_loss') { cardClass = 'closed-loss'; stateTag = '❌止损'; tagClass = 'closed-loss'; }
|
||
else if (status === 'closed_expired') { cardClass = 'closed-expired'; stateTag = '⏰过期'; tagClass = 'closed-expired'; }
|
||
|
||
const pnl = r.pnl_pct || 0;
|
||
const pnlClass = pnl >= 0 ? 'green' : 'red';
|
||
const pnlSign = pnl >= 0 ? '+' : '';
|
||
const currentPrice = r.current_price || r.recommended_price || 0;
|
||
const maxProfit = r.max_profit_pct || 0;
|
||
const maxLoss = r.max_loss_pct || 0;
|
||
|
||
const signals = (r.signals||'').split(',').filter(Boolean);
|
||
|
||
let entryHtml = '';
|
||
if (r.entry_price > 0 && (status === 'active' || status === 'tp1_hit')) {
|
||
const rr1 = r.rr1 || 0;
|
||
const rr2 = r.rr2 || 0;
|
||
entryHtml = `<div class="entry-card">
|
||
<div class="entry-title">📊 入场方案</div>
|
||
<div class="entry-row"><span class="key">入场</span><span class="val">$${r.entry_price.toFixed(4)}</span></div>
|
||
<div class="entry-row"><span class="key">止损</span><span class="val red">$${r.stop_loss.toFixed(4)} (${r.stop_pct}%)</span></div>
|
||
<div class="entry-row"><span class="key">TP1</span><span class="val green">$${r.tp1.toFixed(4)} (RR=${rr1})</span></div>
|
||
<div class="entry-row"><span class="key">TP2</span><span class="val green">$${r.tp2.toFixed(4)} (RR=${rr2})</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
// PnL条
|
||
let pnlBarHtml = '';
|
||
if (r.recommended_price > 0) {
|
||
const pnlAbs = Math.abs(pnl);
|
||
const barWidth = Math.min(pnlAbs * 5, 100); // 1% = 5px宽,max 100%
|
||
pnlBarHtml = `<div class="pnl-bar"><div class="fill ${pnlClass}" style="width:${barWidth}%"></div></div>`;
|
||
}
|
||
|
||
const closeInfo = r.close_reason ? `<div class="rec-details" style="margin-top:4px"><span>${r.close_reason}</span></div>` : '';
|
||
|
||
return `<div class="rec-card ${cardClass}">
|
||
<div class="rec-header">
|
||
<div class="rec-symbol">${r.symbol.replace('/USDT','')}</div>
|
||
<div class="rec-state ${tagClass}">${stateTag}</div>
|
||
</div>
|
||
<div class="rec-price">
|
||
<span class="current">$${currentPrice.toFixed(4)}</span>
|
||
<span class="change ${pnlClass}">${pnlSign}${pnl.toFixed(2)}%</span>
|
||
</div>
|
||
<div class="rec-details">
|
||
推荐价: $${(r.recommended_price||0).toFixed(4)} | 最高: +${maxProfit.toFixed(2)}% | 最低: ${maxLoss.toFixed(2)}%
|
||
${r.sector?' | 板块:'+r.sector:''}
|
||
</div>
|
||
${closeInfo}
|
||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||
${entryHtml}
|
||
${pnlBarHtml}
|
||
</div>`;
|
||
}
|
||
|
||
function formatTime(t) {
|
||
if (!t || t === '--') return '--';
|
||
try {
|
||
const d = new Date(t);
|
||
const month = (d.getMonth()+1).toString().padStart(2,'0');
|
||
const day = d.getDate().toString().padStart(2,'0');
|
||
const hour = d.getHours().toString().padStart(2,'0');
|
||
const min = d.getMinutes().toString().padStart(2,'0');
|
||
return `${month}-${day} ${hour}:${min}`;
|
||
} catch(e) { return t; }
|
||
}
|
||
|
||
// 首次加载
|
||
loadData();
|
||
</script>
|
||
</body>
|
||
</html> |