alphax/static/sentiment.html
2026-05-15 11:50:26 +08:00

384 lines
20 KiB
HTML
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.

{% extends "base.html" %}
{% block title %}AlphaX Agent Crypto — 舆情雷达{% endblock %}
{% block nav_links %}
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
<a class="sidebar-link" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
<div class="sidebar-section-label admin-link" style="display:none">研发</div>
<a class="sidebar-link admin-link" href="/pipeline" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>链路日志</a>
<a class="sidebar-link admin-link" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
<a class="sidebar-link admin-link" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
<a class="sidebar-link admin-link" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
{% endblock %}
{% block extra_head_css %}
<style>
/* SHELL */
.shell { position: relative; z-index: 1; width: min(100% - 40px, 960px); margin: 0 auto; padding: 24px 0; }
/* Page title */
.page-title { font-size: 24px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 18px; }
/* === SECTION: DASHBOARD === */
.market-context {
display: grid; grid-template-columns: minmax(210px, 260px) 1fr;
gap: 10px; margin-bottom: 18px;
}
@media(max-width:720px) { .market-context { grid-template-columns: 1fr; } }
/* Fear & Greed */
.fg-card {
background: var(--canvas); border: 1px solid var(--hairline-soft);
border-radius: var(--radius-lg); padding: 10px 12px;
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px 10px;
}
.fg-card .fg-label { grid-column: 1 / -1; font-size: 11px; color: var(--stone); font-weight: 800; }
.fg-card .fg-value { font-size: 28px; font-weight: 900; line-height: 1; transition: color .3s; }
.fg-card .fg-class { justify-self: start; font-size: 12px; font-weight: 800; padding: 3px 9px; border-radius: var(--radius-full); }
.fg-gauge { grid-column: 1 / -1; width: 100%; height: 5px; border-radius: 999px; background: linear-gradient(to right, #e53e3e, #f59e0b, #84cc16); position: relative; }
.fg-gauge::after {
content: ""; position: absolute; top: -3px;
width: 11px; height: 11px; border-radius: 50%; background: var(--canvas);
border: 2px solid var(--ink); transition: left .5s;
left: calc(var(--pos, 0%) - 5px);
}
/* Trending card */
.trend-card {
background: var(--canvas); border: 1px solid var(--hairline-soft);
border-radius: var(--radius-lg); padding: 10px 12px; min-width: 0;
}
.trend-card .section-label { font-size: 11px; color: var(--stone); font-weight: 800; margin-bottom: 8px; }
.trend-list { display: flex; flex-wrap: wrap; gap: 6px; min-height: 29px; align-items: center; }
.trend-pill { display: inline-flex; align-items: center; gap: 6px; max-width: 160px; padding: 4px 8px; border: 1px solid var(--hairline-soft); border-radius: 999px; background: var(--surface); color: var(--slate); font-size: 12px; font-weight: 800; }
.trend-pill img { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; }
.trend-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.trend-symbol { font-size: 11px; color: var(--stone); margin-left: 4px; }
.trend-rank { font-size: 10px; color: var(--muted); }
/* === SECTION: NEWS FEED === */
.feed-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.feed-header h2 { font-size: 18px; font-weight: 700; }
.feed-header .feed-count { font-size: 12px; color: var(--muted); background: var(--surface); padding: 2px 10px; border-radius: var(--radius-full); }
.news-feed { display: flex; flex-direction: column; gap: 8px; }
.news-card {
background: var(--canvas); border: 1px solid var(--hairline-soft);
border-radius: var(--radius-xl); padding: 16px 18px;
transition: .15s; cursor: pointer; display: flex; gap: 14px; align-items: flex-start;
}
.news-card:hover { border-color: var(--hairline); box-shadow: 0 2px 8px rgba(5,0,56,.04); }
.news-card:active { transform: scale(.995); }
.news-source {
flex-shrink: 0; min-width: 56px; font-size: 10px; font-weight: 700;
color: var(--stone); padding: 4px 8px; background: var(--surface);
border-radius: var(--radius-md); text-align: center; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.news-source.cn { color: var(--blue); background: rgba(66,98,255,.06); }
.news-body { flex: 1; min-width: 0; }
.news-title { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 6px; }
.news-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); }
.news-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--hairline); }
.ai-brief { margin-top: 8px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 10px 12px; }
.ai-brief .label { font-size: 10px; color: var(--stone); font-weight: 900; margin-bottom: 4px; }
.ai-brief .text { font-size: 12px; color: var(--slate); line-height: 1.55; }
.ai-brief .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.ai-brief .chip { display: inline-flex; padding: 3px 7px; border-radius: 999px; border: 1px solid var(--hairline-soft); background: var(--canvas); color: var(--slate); font-size: 10px; }
.analysis-card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 18px; margin-bottom: 18px; }
.analysis-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; }
.analysis-title { font-size: 16px; font-weight: 900; color: var(--ink); }
.analysis-meta { color: var(--stone); font-size: 11px; font-weight: 800; text-align:right; line-height:1.5; }
.mood { display:inline-flex; border-radius:999px; padding:4px 9px; font-size:11px; font-weight:900; background:var(--surface); color:var(--slate); border:1px solid var(--hairline-soft); }
.mood.risk_on { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.18); }
.mood.risk_off { color: var(--red); background: var(--red-light); border-color: rgba(229,62,62,.18); }
.analysis-summary { color: var(--slate); font-size: 14px; line-height: 1.75; margin-bottom: 14px; }
.analysis-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.analysis-section { border:1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 12px; min-width:0; }
.analysis-section h3 { font-size: 12px; font-weight: 900; color: var(--ink); margin-bottom: 9px; }
.analysis-item { border-top:1px solid var(--hairline-soft); padding:8px 0; color:var(--slate); font-size:12px; line-height:1.55; }
.analysis-item:first-of-type { border-top:0; padding-top:0; }
.analysis-item b { color:var(--ink); font-size:13px; }
.analysis-item .sub { display:block; margin-top:3px; color:var(--stone); }
.symbol-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
.symbol-tag { display:inline-flex; padding:3px 7px; border-radius:999px; background:var(--canvas); border:1px solid var(--hairline-soft); color:var(--blue); font-size:10px; font-weight:900; }
@media(max-width:760px){ .analysis-grid{grid-template-columns:1fr;} .analysis-head{display:block;} .analysis-meta{text-align:left;margin-top:8px;} }
/* Empty */
.empty-state { text-align:center; padding:48px 20px; color:var(--stone); }
.empty-state p { font-size:14px; }
/* Loading */
.loading-pulse { animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.3 } }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to{ transform:rotate(360deg) } }
@media(max-width:640px) {
.shell { width: min(100% - 24px, 960px); }
.news-card { padding: 14px 14px; gap: 10px; }
.news-source { min-width: 48px; font-size: 9px; padding: 3px 6px; }
}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<h1 class="page-title">实时舆情</h1>
<p class="page-sub">AI 舆情研判 + 本轮分析来源</p>
<div id="aiAnalysis" class="analysis-card">
<div class="empty-state"><p>等待 AI 舆情分析结果...</p></div>
</div>
<!-- Dashboard -->
<div class="market-context">
<div class="fg-card" id="fgCard">
<div class="fg-label">恐惧 & 贪婪指数</div>
<div class="fg-value loading-pulse" id="fgValue">--</div>
<div class="fg-class" id="fgClass">加载中</div>
<div class="fg-gauge" id="fgGauge"></div>
</div>
<div class="trend-card">
<div class="section-label">CoinGecko 热门币种</div>
<div class="trend-list loading-pulse" id="trendList">
<span class="trend-pill">加载中...</span>
</div>
</div>
</div>
<!-- Source Feed -->
<div class="feed-header">
<h2>本轮分析来源</h2>
<span class="feed-count" id="feedCount">--</span>
</div>
<div class="news-feed" id="newsFeed">
<div class="empty-state"><p>加载中...</p></div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
var API = '';
// ====== USER ======
var currentUser = null;
var $ = function(id){ return document.getElementById(id); };
async function loadUser() {
try {
var resp = await fetch(API + '/api/auth/me');
if (!resp.ok) return;
var data = await resp.json();
currentUser = data.user;
var email = currentUser.email || '--';
$('userInitial').textContent = email.charAt(0).toUpperCase();
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '…' : email;
$('ddEmail').textContent = email;
} catch(e) {}
}
function toggleUserMenu() {
$('userDropdown').classList.toggle('show');
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show');
});
function showChangePwd() {
$('userDropdown').classList.remove('show');
$('pwdModal').classList.add('show');
$('oldPwd').value = ''; $('newPwd').value = ''; $('cfmPwd').value = ''; $('pwdMsg').textContent = '';
}
function closePwdModal() { $('pwdModal').classList.remove('show'); }
async function changePwd() {
var old = $('oldPwd').value, nw = $('newPwd').value, cf = $('cfmPwd').value;
if (!old || !nw) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '请填写所有字段'; return; }
if (nw.length < 8) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '新密码至少 8 位'; return; }
if (nw !== cf) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '两次密码不一致'; return; }
try {
var r = await fetch(API + '/api/auth/change-password', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password: old, new_password: nw})
});
var d = await r.json();
if (!r.ok) throw new Error(d.detail || '修改失败');
$('pwdMsg').className = 'modal-msg ok'; $('pwdMsg').textContent = d.message || '修改成功';
setTimeout(closePwdModal, 1200);
} catch(e) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = e.message; }
}
async function doLogout() {
await fetch(API + '/api/auth/logout', { method: 'POST' });
window.location.href = '/auth';
}
// ====== FEED ======
function ageStr(h) {
if (h == null) return '';
if (h < 1) return Math.round(h * 60) + '分钟前';
if (h < 24) return Math.floor(h) + '小时前';
return Math.floor(h / 24) + '天前';
}
function esc(v){ return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];}); }
function fgColor(v) {
if (v <= 25) return 'var(--red)';
if (v <= 45) return 'var(--orange)';
if (v <= 55) return 'var(--yellow)';
if (v <= 75) return 'var(--green)';
return 'var(--green)';
}
function fmtTime(t) {
if (!t) return '--';
var d = new Date(t);
if (isNaN(d.getTime())) return t;
return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
}
function renderAnalysis(resp) {
var box = document.getElementById('aiAnalysis');
var a = resp && resp.analysis ? resp.analysis : null;
if (!a) {
var msg = resp && resp.error ? ('AI 舆情分析未完成:' + resp.error) : '暂无 AI 舆情分析。调度器会定时生成,或运行 llm-insights --scope sentiment。';
box.innerHTML = '<div class="empty-state"><p>'+esc(msg)+'</p></div>';
return;
}
var mood = a.market_mood || 'neutral';
function symbolsHtml(items) {
return (items || []).slice(0, 8).map(function(s){ return '<span class="symbol-tag">'+esc(s)+'</span>'; }).join('');
}
var themes = (a.hot_themes || []).slice(0, 6).map(function(x){
return '<div class="analysis-item"><b>'+esc(x.theme || '主题')+'</b><span class="sub">'+esc(x.impact || x.reason || '--')+'</span><div class="symbol-tags">'+symbolsHtml(x.symbols || [])+'</div></div>';
}).join('') || '<div class="analysis-item">暂无明确主题</div>';
var impacts = (a.coin_impacts || []).slice(0, 8).map(function(x){
var check = x.need_technical_check ? ' · 需要技术检查' : '';
return '<div class="analysis-item"><b>'+esc(x.symbol || '--')+'</b><span class="sub">'+esc((x.direction || 'neutral') + check)+'</span><span class="sub">'+esc(x.reason || '--')+'</span></div>';
}).join('') || '<div class="analysis-item">暂无币种影响</div>';
var risks = (a.risk_events || []).slice(0, 6).map(function(x){
return '<div class="analysis-item"><b>'+esc(x.risk_type || x.title || '风险事件')+'</b><span class="sub">'+esc(x.title || x.reason || '--')+'</span><div class="symbol-tags">'+symbolsHtml(x.symbols || [])+'</div></div>';
}).join('') || '<div class="analysis-item">暂无集中风险</div>';
var watch = (a.watchlist || []).slice(0, 8).map(function(x){
return '<div class="analysis-item"><b>'+esc(x.symbol || '--')+'</b><span class="sub">'+esc(x.why || '--')+'</span><span class="sub">触发条件:'+esc(x.trigger || '--')+'</span></div>';
}).join('') || '<div class="analysis-item">暂无重点观察</div>';
box.innerHTML =
'<div class="analysis-head"><div><div class="analysis-title">AI 舆情研判 <span class="mood '+esc(mood)+'">'+esc(mood)+'</span></div></div><div class="analysis-meta">模型 '+esc(resp.model || '--')+'<br>更新 '+fmtTime(resp.updated_at)+' · 来源 '+esc(resp.event_count || 0)+' 条</div></div>'+
'<div class="analysis-summary">'+esc(a.summary || '暂无摘要')+'</div>'+
'<div class="analysis-grid">'+
'<div class="analysis-section"><h3>主线主题</h3>'+themes+'</div>'+
'<div class="analysis-section"><h3>币种影响</h3>'+impacts+'</div>'+
'<div class="analysis-section"><h3>风险事件</h3>'+risks+'</div>'+
'<div class="analysis-section"><h3>重点观察</h3>'+watch+'</div>'+
'</div>';
}
async function loadFeed() {
try {
var resp = await fetch(API + '/api/newsfeed');
var data = await resp.json();
var analysisResp = {};
try {
var ar = await fetch(API + '/api/sentiment/analysis');
if (ar.ok) analysisResp = await ar.json();
} catch(e) {}
renderAnalysis(analysisResp);
// Fear & Greed
var fg = data.fear_greed;
if (fg) {
var v = fg.value, cls = fg.classification, clr = fgColor(v);
document.getElementById('fgValue').textContent = v;
document.getElementById('fgValue').style.color = clr;
document.getElementById('fgValue').classList.remove('loading-pulse');
document.getElementById('fgClass').textContent = cls;
document.getElementById('fgClass').style.color = clr;
document.getElementById('fgClass').style.background = clr + '15';
document.getElementById('fgGauge').style.setProperty('--pos', v + '%');
}
// Trending
var trends = data.trending || [];
document.getElementById('trendList').classList.remove('loading-pulse');
if (trends.length) {
document.getElementById('trendList').innerHTML = trends.slice(0, 8).map(function(t) {
var symbol = String(t.symbol || '').toUpperCase();
var icon = t.thumb ? '<img src="' + esc(t.thumb) + '" alt="' + esc(symbol) + '" onerror="this.remove()">' : '';
return '<span class="trend-pill">' +
icon +
'<span class="trend-name">' + esc(t.name || symbol || '--') + '</span>' +
'<span class="trend-symbol">' + esc(symbol) + '</span>' +
'<span class="trend-rank">#' + esc(t.market_cap_rank || '--') + '</span>' +
'</span>';
}).join('');
} else {
document.getElementById('trendList').innerHTML = '<span style="color:var(--muted);font-size:12px">暂无数据</span>';
}
// Source feed: only show the events/news that fed the latest AI analysis.
var events = ((analysisResp && analysisResp.source_events) || []).map(function(e){
return {
title: e.title,
url: e.url,
source: e.source_label || e.source || '事件',
age_hours: null,
lang: e.related_base || '事件',
llm_insight: null,
relation_tag: e.related_symbol || e.related_base || '',
importance: e.importance
};
});
var news = events.slice(0, 50);
document.getElementById('feedCount').textContent = news.length + ' 条';
if (news.length) {
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
var isCn = n.lang === 'cn';
var langLabel = n.relation_tag || (isCn ? '中文' : (n.importance ? ('重要性 ' + n.importance) : 'EN'));
var ai = n.llm_insight && n.llm_insight.content ? n.llm_insight.content : null;
var tags = ai ? (ai.key_tags || ai.theme_tags || ai.risk_types || []) : [];
var aiHtml = ai ? (
'<div class="ai-brief">' +
'<div class="label">AI 解读</div>' +
'<div class="text">' + esc(ai.summary || ai.why_now_or_not || '暂无摘要') + '</div>' +
'<div class="chips">' + (tags.slice(0,4).map(function(x){ return '<span class="chip">'+esc(x)+'</span>'; }).join('') || '') + '</div>' +
'</div>'
) : '';
return '<a class="news-card" href="' + esc(n.url || '#') + '" target="_blank" rel="noopener">' +
'<span class="news-source' + (isCn ? ' cn' : '') + '">' + esc(n.source) + '</span>' +
'<div class="news-body">' +
'<div class="news-title">' + esc(n.title) + '</div>' +
'<div class="news-meta">' +
'<span>' + esc(langLabel) + '</span>' +
'<span class="dot"></span>' +
'<span>' + ageStr(n.age_hours) + '</span>' +
'</div>' +
aiHtml +
'</div>' +
'</a>';
}).join('');
} else {
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>暂无新闻数据</p></div>';
}
} catch(e) {
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>加载失败,请稍后重试</p></div>';
}
}
loadUser();
loadFeed();
// Auto-refresh every 5 minutes
setInterval(loadFeed, 300000);
</script>
{% endblock %}