oilverse-api / frontend /src /components /EventTimeline.jsx
孙家明
deploy: OilVerse for HuggingFace (Node.js 18 fix)
fab9847
Raw
History Blame Contribute Delete
9.08 kB
import { useState, useEffect } from 'react';
import { fetchEvents } from '../api';
const TYPE_STYLE = {
geopolitical: { color: '#ef4444', bg: 'rgba(239,68,68,.12)', label: '地缘' },
supply: { color: '#10b981', bg: 'rgba(16,185,129,.12)', label: '供给' },
demand: { color: '#f59e0b', bg: 'rgba(245,158,11,.12)', label: '需求' },
macro: { color: '#3b82f6', bg: 'rgba(59,130,246,.12)', label: '宏观' },
policy: { color: '#8b5cf6', bg: 'rgba(139,92,246,.12)', label: '政策' },
};
const IMPACT_COLOR = { bullish: '#10b981', bearish: '#ef4444', neutral: '#94a3b8' };
const IMPACT_LABEL = { bullish: '利多', bearish: '利空', neutral: '中性' };
const FACTOR_ZH = {
vix_lag1: 'VIX', usd_index: 'USD指数', supply_saudi: '沙特产量',
Brent_spot: 'Brent', mom1m_lag1: '动量', natgas_spot_henry: '天然气',
pmi_us_mfg: 'PMI制造', ipi_us: '工业产出', rig_count_us_new: '钻井数',
news_oil_sentiment: '新闻情绪', news_geo_tone: '地缘情绪',
};
export default function EventTimeline({ limit = 5 }) {
const [events, setEvents] = useState([]);
const [expanded, setExpanded] = useState(null);
const [filter, setFilter] = useState('all');
useEffect(() => {
fetchEvents().then(d => {
if (Array.isArray(d)) {
d.sort((a, b) => b.date.localeCompare(a.date));
setEvents(d);
}
}).catch(() => {});
}, []);
const filtered = filter === 'all' ? events : events.filter(e => e.type === filter);
const shown = filtered.slice(0, expanded !== null ? 50 : limit);
if (!events.length) return null;
return (
<div className="card" style={{ padding: '1.2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '.8rem' }}>
<h3 style={{ margin: 0 }}>
🔗 因果叙事链
<span style={{ fontSize: '.75rem', fontWeight: 400, color: 'var(--muted)', marginLeft: 8 }}>
事件 → 因子异动 → 风险信号 → 对冲建议
</span>
</h3>
<div style={{ display: 'flex', gap: 6 }}>
{[['all', '全部'], ...Object.entries(TYPE_STYLE).map(([k, v]) => [k, v.label])].map(([k, label]) => (
<button key={k}
onClick={() => setFilter(k)}
style={{
background: filter === k ? (TYPE_STYLE[k]?.bg || 'rgba(99,102,241,.15)') : 'rgba(255,255,255,.04)',
color: filter === k ? (TYPE_STYLE[k]?.color || 'var(--accent)') : 'var(--muted)',
border: `1px solid ${filter === k ? (TYPE_STYLE[k]?.color || 'var(--accent)') + '44' : 'transparent'}`,
borderRadius: 12, padding: '3px 10px', fontSize: '.75rem', cursor: 'pointer',
}}>
{label}
</button>
))}
</div>
</div>
{/* Timeline */}
<div style={{ position: 'relative', paddingLeft: 24 }}>
{/* Vertical line */}
<div style={{
position: 'absolute', left: 9, top: 8, bottom: 8, width: 2,
background: 'linear-gradient(to bottom, rgba(99,102,241,.4), rgba(99,102,241,.05))',
}} />
{shown.map((ev, i) => {
const ts = TYPE_STYLE[ev.type] || TYPE_STYLE.macro;
const isOpen = expanded === i;
return (
<div key={i} style={{ position: 'relative', marginBottom: isOpen ? 16 : 10 }}>
{/* Dot on timeline */}
<div style={{
position: 'absolute', left: -20, top: 6, width: 14, height: 14,
borderRadius: '50%', background: ts.color,
border: '2px solid #0d1117', zIndex: 1,
boxShadow: isOpen ? `0 0 8px ${ts.color}88` : 'none',
}} />
{/* Event card */}
<div
onClick={() => setExpanded(isOpen ? null : i)}
style={{
background: isOpen ? ts.bg : 'rgba(255,255,255,.02)',
border: `1px solid ${isOpen ? ts.color + '33' : 'rgba(255,255,255,.06)'}`,
borderRadius: 10, padding: '10px 14px', cursor: 'pointer',
transition: 'all .2s ease',
}}>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '1.1rem' }}>{ev.icon}</span>
<span style={{ fontWeight: 600, fontSize: '.9rem' }}>{ev.title}</span>
<span style={{
fontSize: '.7rem', padding: '1px 8px', borderRadius: 8,
background: IMPACT_COLOR[ev.impact] + '22', color: IMPACT_COLOR[ev.impact],
fontWeight: 600,
}}>
{IMPACT_LABEL[ev.impact]}
</span>
<span style={{ marginLeft: 'auto', fontSize: '.75rem', color: 'var(--muted)' }}>
{ev.date}
</span>
</div>
{/* Expanded content: causal chain */}
{isOpen && (
<div style={{ marginTop: 12 }}>
{/* Narrative */}
<div style={{ fontSize: '.85rem', color: 'var(--muted)', lineHeight: 1.6, marginBottom: 12 }}>
{ev.narrative}
</div>
{/* Causal chain visualization */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8,
background: 'rgba(0,0,0,.2)', borderRadius: 8, padding: 12,
}}>
{/* Step 1: Event */}
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '.65rem', color: ts.color, fontWeight: 600, marginBottom: 4 }}>① 事件触发</div>
<div style={{ fontSize: '1.3rem' }}>{ev.icon}</div>
<div style={{ fontSize: '.75rem', marginTop: 4 }}>{ev.title.slice(0, 12)}</div>
</div>
{/* Step 2: Factor changes */}
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '.65rem', color: '#3b82f6', fontWeight: 600, marginBottom: 4 }}>② 因子异动</div>
{ev.factor_changes && Object.entries(ev.factor_changes).slice(0, 3).map(([k, v]) => (
<div key={k} style={{ fontSize: '.75rem', lineHeight: 1.6 }}>
{FACTOR_ZH[k] || k}{' '}
<span style={{ color: v > 0 ? '#10b981' : '#ef4444', fontWeight: 700 }}>
{v > 0 ? '↑' : '↓'}{Math.abs(v).toFixed(1)}
</span>
</div>
))}
</div>
{/* Step 3: Risk signal */}
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '.65rem', color: '#f59e0b', fontWeight: 600, marginBottom: 4 }}>③ 风险信号</div>
<div style={{ fontSize: '.75rem', lineHeight: 1.5, color: 'var(--muted)' }}>
{ev.risk_signal}
</div>
</div>
{/* Step 4: Hedging action */}
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '.65rem', color: '#10b981', fontWeight: 600, marginBottom: 4 }}>④ 对冲建议</div>
<div style={{ fontSize: '.75rem', lineHeight: 1.5, color: 'var(--muted)' }}>
{ev.hedging_action}
</div>
</div>
</div>
{/* Arrow chain */}
<div style={{
display: 'flex', justifyContent: 'center', gap: 4, marginTop: 6,
fontSize: '.7rem', color: 'var(--muted)',
}}>
<span style={{ color: ts.color }}>事件</span>
<span style={{ color: '#3b82f6' }}>因子</span>
<span style={{ color: '#f59e0b' }}>信号</span>
<span style={{ color: '#10b981' }}>对冲</span>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
{/* Show more */}
{filtered.length > limit && expanded === null && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<button
onClick={() => setExpanded(-1)}
style={{
background: 'none', border: '1px solid rgba(99,102,241,.3)',
color: 'var(--accent)', borderRadius: 8, padding: '4px 16px',
fontSize: '.8rem', cursor: 'pointer',
}}>
查看全部 {filtered.length} 个事件
</button>
</div>
)}
</div>
);
}