// ========================================================= // FBIF 资讯订阅 · Timeline view (核心界面 · 真·时间线) // Centered, readable feed on a vertical time axis. // Date dividers + per-card timestamp dots + connecting line. // Click a card → opens the reader preview (handled in App). // ========================================================= // segmented control (shared) const Seg = ({ value, onChange, options, size = 28 }) => (
{options.map(o => { const active = value === o.value; return ( ); })}
); function clock(iso) { const d = new Date(iso); const p = n => String(n).padStart(2, '0'); return `${p(d.getHours())}:${p(d.getMinutes())}`; } function dayLabel(iso) { const d = new Date(iso); const today = new Date(); const yest = new Date(); yest.setDate(today.getDate() - 1); const same = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); const wd = ['周日','周一','周二','周三','周四','周五','周六'][d.getDay()]; const md = `${d.getMonth() + 1} 月 ${d.getDate()} 日`; if (same(d, today)) return `今天 · ${md} ${wd}`; if (same(d, yest)) return `昨天 · ${md} ${wd}`; return `${md} ${wd}`; } // ---- timeline card ---- const TLCard = ({ entry, active, onOpen, compact = false, narrow = false }) => { const [hover, setHover] = React.useState(false); const [focus, setFocus] = React.useState(false); const tags = entry.tags.map(id => window.FBIF_DATA.TAGS.find(t => t.id === id)).filter(Boolean); const indexMode = compact || narrow; const sourceMax = indexMode ? 120 : 180; const handleKey = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry); } }; return (
onOpen(entry)} onKeyDown={handleKey} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onFocus={() => setFocus(true)} onBlur={() => setFocus(false)} style={{ position: 'relative', border: '1px solid', borderColor: active ? '#A8C3FB' : hover ? '#C9CDD4' : '#E5E6EB', borderRadius: 8, background: active ? '#F7FAFF' : '#fff', padding: indexMode ? '10px 12px' : '12px 14px', cursor: 'pointer', transition: 'border-color .15s, background-color .15s, box-shadow .15s', boxShadow: focus ? '0 0 0 3px rgba(20,86,240,.14)' : hover ? '0 2px 8px rgba(31,35,41,.05)' : 'none', }}>
{entry.source_name.replace(/\s*Newsroom/, '')} {!narrow && } {!narrow && } {!indexMode && entry.quality_score != null && }

{entry.title}

{!indexMode && (

{entry.summary}

)} {!indexMode &&
{tags.slice(0, 3).map(t => ( {t.name} ))} {entry.related > 0 && ( 关联讨论 {entry.related} 条 )} {entry.is_featured && }
}
); }; // ---- axis row (timestamp + dot + line) wrapping a card ---- const AxisRow = ({ entry, last, children, compact = false }) => (
{clock(entry.published_at)} {!last && }
{children}
); // ---- states ---- const TLSkeleton = () => (
); const Centered = ({ icon, title, hint, action }) => { useLucide(); return (
{title}
{hint}
{action &&
{action}
}
); }; const DemoMenu = ({ value, onPick, onClose }) => { useLucide(); const items = [ { v: null, l: '正常', icon: 'circle-check' }, { v: 'loading', l: '加载中', icon: 'loader' }, { v: 'empty', l: '空状态', icon: 'inbox' }, { v: 'error', l: '加载失败', icon: 'cloud-off' }, ]; return ( <>
状态演示
{items.map(it => { const active = value === it.v || (it.v === null && !value); return (
{ onPick(it.v); onClose(); }} style={{ display: 'flex', alignItems: 'center', gap: 8, height: 32, padding: '0 10px', borderRadius: 6, cursor: 'pointer', fontSize: 13, color: active ? '#0F4DD1' : '#1F2329', background: active ? '#EBF1FE' : 'transparent' }}> {it.l} {active && }
); })}
); }; const TimelineView = ({ entries, status, view, scope, scopeLabel, tab, onTab, density, onDensity, activeId, onOpen, onRetry, onManage, onGoTimeline, counts, railOpen, onOpenRail, appRailWidth = 76, onDemo, demo, readerOpen = false, }) => { useLucide(); const [narrow, setNarrow] = React.useState(typeof window !== 'undefined' ? window.innerWidth < 760 : false); const showQualitySwitch = counts.featured > 0 || tab === 'featured'; const contentMaxWidth = readerOpen ? 680 : 880; const compactList = readerOpen || narrow; React.useEffect(() => { const onResize = () => setNarrow(window.innerWidth < 760); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // group by day const groups = []; entries.forEach(e => { const label = dayLabel(e.published_at); let g = groups.find(x => x.label === label); if (!g) { g = { label, items: [] }; groups.push(g); } g.items.push(e); }); return (
{/* feed */}
{showQualitySwitch && (
)} {status === 'loading' && Array.from({ length: 5 }).map((_, i) => )} {status === 'error' && 重试} />} {status === 'empty' && 去订阅管理} />} {status === 'ready' && entries.length === 0 && ( 去订阅管理} /> )} {status === 'ready' && groups.map((g, gi) => (
{/* date divider */}
{g.label} {g.items.length} 条
{g.items.map((e, i) => ( ))}
))} {status === 'ready' && entries.length > 0 && (
没有更多了
)}
); }; Object.assign(window, { TimelineView, Seg });