// ========================================================= // FBIF 资讯订阅 · 订阅管理 (操作台) // Add what to follow — type-aware: // 公众号 : 搜索 → 订阅 (模型对应 今天看啥 search/submit/status 接口) + 极速订阅 // RSS / RSSHub / 网站 : 粘贴链接/路由,添加即做一次健康检查 // Manage list: 健康度 · 类型 · 信源等级 · 标签 · 订阅 · 行内更多(健康检查/极速/失效提醒/解除) // Failed sources raise a banner + can notify via 飞书机器人 (webhook 配置)。 // ========================================================= const TagEditPopover = ({ source, tags, anchorRef, onToggle, onClose }) => (
为「{source.name}」选择标签
{tags.map(t => { const on = source.tags.includes(t.id); return ( onToggle(source.id, t.id)} label={{t.name}} right={on ? : null} /> ); })}
); // row 更多 menu — health recheck / 极速订阅 / 失效提醒 / 解除 const RowMenu = ({ source, anchorRef, onRecheck, onHealthDetail, onToggleFast, onToggleAlert, onUnsub, onClose }) => ( { onRecheck(source); onClose(); }} /> { onHealthDetail(source); onClose(); }} /> {source.type === 'wechat' && ( onToggleFast(source)} />} /> )} onToggleAlert(source)} />} /> { onUnsub(source); onClose(); }} /> ); const SourceRow = ({ source, data, onToggle, onToggleTag, onRecheck, onHealthDetail, onToggleFast, onToggleAlert }) => { const [hover, setHover] = React.useState(false); const [tagOpen, setTagOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false); const tagBtnRef = React.useRef(null); const moreBtnRef = React.useRef(null); const tags = source.tags.map(id => data.TAGS.find(t => t.id === id)).filter(Boolean); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '0 16px', minHeight: 60, borderBottom: '1px solid #EDEFF2', background: hover ? '#F7F8FA' : '#fff' }}>
{source.name} {source.fast && 极速}
{source.article_count || 0} 条资讯
{/* health */}
{source.healthNote || `最近更新 ${timeAgo(source.lastUpdate)}`}
{/* tags */}
{tags.map(t => ( {t.name} ))}
{tagOpen && setTagOpen(false)} />}
onToggle(source)} />
); }; const RiskTag = ({ risk }) => { const m = window.FBIF_DATA.RISK_META[risk]; if (!m) return null; const tones = { warning: { bg: '#FFF3E5', fg: '#B45F00' }, danger: { bg: '#FEECEC', fg: '#B83430' } }; const t = tones[m.tone] || tones.warning; return ( {m.label} ); }; // ---- search-first add panel ---- const norm = s => String(s || '').toLowerCase().replace(/\s+/g, ''); const isUrlLike = s => /^https?:\/\//i.test(s) || /^[\w.-]+\.[a-z]{2,}/i.test(s); const directUrl = s => /^https?:\/\//i.test(s) ? s : `https://${s}`; const SearchResultRow = ({ item, active, disabled, compact, onClick, onPrimary }) => { useLucide(); const typeMeta = { wechat: { icon: 'message-square', label: '公众号', color: '#1AAD19' }, source: { icon: item.source_type === 'web' ? 'globe' : item.source_type === 'rss' ? 'rss' : 'newspaper', label: item.typeLabel, color: item.color || '#1456F0' }, rsshub: { icon: 'route', label: 'RSSHub', color: '#1456F0' }, url: { icon: item.type === 'rss' ? 'rss' : 'globe', label: item.type === 'rss' ? 'RSS' : '网站', color: item.type === 'rss' ? '#FF8800' : '#1456F0' }, }[item.kind]; return (
{item.title} {typeMeta.label} {!compact && item.risks && item.risks.map(r => )}
{item.desc}
{disabled ? : item.kind === 'wechat' || item.kind === 'source' ? : }
); }; const SmartAdd = ({ data, onAdd, onToggleSource, onSubscribeCandidate }) => { useLucide(); const [q, setQ] = React.useState(''); const [selected, setSelected] = React.useState(null); const [params, setParams] = React.useState({}); const [err, setErr] = React.useState(''); const [preview, setPreview] = React.useState(null); const [compact, setCompact] = React.useState(typeof window !== 'undefined' ? window.innerWidth < 760 : false); const query = q.trim(); const nq = norm(query); const base = 'https://rsshub.app'; const subscribedNames = new Set(data.SOURCES.filter(s => s.subscribed).map(s => s.name)); React.useEffect(() => { const onResize = () => setCompact(window.innerWidth < 760); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); const routeItems = (data.RSSHUB_ROUTES || []).map(r => { const hay = norm([r.platform, r.name, r.route, r.desc, ...(r.keywords || [])].join(' ')); const exact = nq && (norm(r.platform) === nq || norm(r.name) === nq); const score = !nq ? 0 : exact ? 100 : hay.includes(nq) ? 60 : 0; return { kind: 'rsshub', id: r.id, title: `${r.platform} · ${r.name}`, desc: r.desc, route: r, risks: r.risks, score }; }).filter(x => !nq || x.score > 0); const wechatItems = data.WECHAT_CANDIDATES.map(c => { const hay = norm(`${c.name} ${c.desc}`); const score = !nq ? 0 : hay.includes(nq) ? 80 : 0; return { kind: 'wechat', id: c.slug, title: c.name, desc: c.desc, candidate: c, subscribed: subscribedNames.has(c.name), score }; }).filter(x => !nq || x.score > 0); const sourceItems = data.SOURCES.filter(s => !s.subscribed || nq).map(s => { const tags = s.tags.map(id => data.TAGS.find(t => t.id === id)?.name).filter(Boolean).join(' '); const hay = norm(`${s.name} ${s.tier} ${tags} ${s.type}`); const score = !nq ? (s.subscribed ? 0 : 20) : hay.includes(nq) ? 70 : 0; const typeLabel = window.FBIF_DATA.TYPE_META[s.type]?.label || '来源'; return { kind: 'source', id: s.id, title: s.name, desc: `${typeLabel} · ${s.tier} · ${s.article_count || 0} 条资讯`, source: s, source_type: s.type, typeLabel, color: s.color, subscribed: s.subscribed, score }; }).filter(x => !nq || x.score > 0); const urlItems = query && (isUrlLike(query) || query.startsWith('/')) ? [{ kind: 'url', id: 'direct-url', title: query.startsWith('/') ? `RSSHub · ${query.split('/').filter(Boolean)[0] || 'route'}` : query.replace(/^https?:\/\//i, '').replace(/\/$/, ''), desc: query.startsWith('/') ? `${base}${query}` : '自动探测 RSS / Atom,可直接添加网站或 feed 地址', type: query.startsWith('/') || /feed|rss|atom/i.test(query) ? 'rss' : 'web', routePath: query.startsWith('/') ? query : null, url: query.startsWith('/') ? `${base}${query}` : directUrl(query), score: 90, }] : []; const defaultItems = [ ...data.SOURCES.filter(s => !s.subscribed).slice(0, 2).map(s => ({ kind: 'source', id: s.id, title: s.name, desc: `${window.FBIF_DATA.TYPE_META[s.type]?.label || '来源'} · ${s.tier}`, source: s, source_type: s.type, typeLabel: window.FBIF_DATA.TYPE_META[s.type]?.label || '来源', color: s.color, subscribed: false, score: 20, })), ...routeItems.filter(x => ['r-bili', 'r-36kr', 'r-weibo'].includes(x.id)), ]; const results = (query ? [...urlItems, ...wechatItems, ...sourceItems, ...routeItems] : defaultItems) .sort((a, b) => b.score - a.score) .slice(0, 8); const select = item => { setSelected(item); setParams({}); setErr(''); setPreview(null); }; const followFast = item => { if (item.subscribed) return; if (item.kind === 'wechat') onSubscribeCandidate(item.candidate); if (item.kind === 'source') onToggleSource(item.source); setQ(''); setSelected(null); }; const buildRoutePath = item => item.route.route.replace(/:(\w+)/g, (m, k) => params[k] ? encodeURIComponent(params[k].trim()) : m); const canPreview = selected && ( selected.kind === 'url' || (selected.kind === 'rsshub' && selected.route.params.every(p => (params[p.k] || '').trim())) ); const doPreview = () => { if (!selected) return; if (selected.kind === 'rsshub' && !canPreview) { setErr('还差一个参数'); return; } const name = selected.kind === 'rsshub' ? selected.title : selected.title; const url = selected.kind === 'rsshub' ? `${base}${buildRoutePath(selected)}` : selected.url; const dup = data.SOURCES.find(s => s.name === name); setErr(''); setPreview({ name, url, risks: selected.risks || [], dup }); }; const confirm = () => { if (!preview) return; onAdd(selected.kind === 'url' && selected.type === 'web' ? 'web' : 'rss', preview.name); setQ(''); setSelected(null); setPreview(null); setParams({}); }; return (
添加订阅 {!compact && RSSHub: rsshub.app}
{ setQ(e.target.value); setSelected(null); setPreview(null); setErr(''); }} onKeyDown={e => { if (e.key === 'Enter') { if (!query) return; const first = results.find(item => !item.subscribed); if (first && (first.kind === 'wechat' || first.kind === 'source')) followFast(first); else if (first) select(first); } }} width="100%" height={40} /> {!query && (
{['B站 UP 主', '微博 用户', '36氪 未来消费', '咖门', 'Food Dive'].map(x => ( ))}
)}
{results.map(item => ( select(item)} onPrimary={followFast} /> ))} {results.length === 0 && (
没找到匹配来源
)}
{selected && (selected.kind === 'rsshub' || selected.kind === 'url') && (
{selected.title} {!compact && selected.kind === 'rsshub' && selected.risks.map(r => )}
{selected.desc}
{selected.kind === 'rsshub' && selected.route.params.length > 0 && (
{selected.route.params.map(p => ( ))}
)}
{selected.kind === 'rsshub' ? `${base}${buildRoutePath(selected)}` : selected.url}
{err &&
{err}
} {preview && (
来源可用 · 近 24h 有更新
{preview.url}
{preview.dup ? : }
)}
)}
); }; const InfoBox = ({ label, value }) => (
{label}
{value}
); const HealthDetailModal = ({ source, onClose, onRecheck }) => { const now = Date.now(); const rows = []; for (let i = 0; i < 5; i++) { const t = new Date(now - i * 6 * 3600000); let ok = true; if (source.health === 'failed') ok = i >= 3; rows.push({ time: fmtTime(t.toISOString()), ok, note: source.health === 'degraded' && i < 2 ? '无新内容' : '' }); } const failType = source.health === 'failed' ? 'HTTP 拉取失败(连续 3 次超时 / 404)' : source.health === 'degraded' ? '内容长期无更新(> 7 天)' : '—'; const advice = source.health === 'failed' ? '检查订阅地址是否变更或失效;可改用 RSSHub 路由,或在「更多」中重新检查。' : source.health === 'degraded' ? '源站近期无更新,属正常波动;可保留观察或降低检查频率。' : '运行正常,无需处理。'; return ( { onRecheck(source); onClose(); }}>立即重新检查}>
最近 5 次检查
{rows.map((r, i) => (
{r.ok ? '通过' : '失败'}{r.note ? ` · ${r.note}` : ''} {r.time}
))}
处理建议 {advice}
); }; const SubscriptionManager = ({ data, onToggleSource, onAddSource, onSubscribeCandidate, onToggleSourceTag, onRecheck, onToggleFast, onToggleAlert, onOpenSettings, onClose }) => { useLucide(); const [q, setQ] = React.useState(''); const [detail, setDetail] = React.useState(null); const [subFilter, setSubFilter] = React.useState('all'); const [compact, setCompact] = React.useState(typeof window !== 'undefined' ? window.innerWidth < 760 : false); const sources = data.SOURCES.filter(s => s.name.includes(q) || s.tier.includes(q)); const shown = sources.filter(s => subFilter === 'all' || s.subscribed); const subCount = data.SOURCES.filter(s => s.subscribed).length; const failed = data.SOURCES.filter(s => s.subscribed && s.health === 'failed'); const degraded = data.SOURCES.filter(s => s.subscribed && s.health === 'degraded'); const healthText = failed.length ? `${failed.length} 个失效` : degraded.length ? `${degraded.length} 个异常` : '全部正常'; React.useEffect(() => { const onResize = () => setCompact(window.innerWidth < 760); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return (

订阅管理

{subCount} 个已订阅 {healthText}
{failed.length > 0 && (
{failed.length} 个来源失效
{failed.map(s => s.name).join('、')}
)}
来源库
来源 类型 健康度 等级 标签 操作
{shown.map(s => ( ))} {shown.length === 0 && (
{q ? '无匹配来源' : subFilter === 'sub' ? '还没有已订阅的来源' : '暂无来源'}
{q &&
}
)}
{detail && s.id === detail.id) || detail} onClose={() => setDetail(null)} onRecheck={onRecheck} />}
); }; Object.assign(window, { SubscriptionManager, TagEditPopover });