// =========================================================
// 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)} />
{menuOpen && setMenuOpen(false)} />}
);
};
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 }) => (
);
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}
))}
);
};
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 });