Spaces:
Build error
Build error
| 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> | |
| ); | |
| } | |