import { useEffect, useState, useCallback } from 'react' import { fetchDataSourceStatuses, fetchIngestStatus, fetchAdminStats, fetchKeyRingStatus, resetKeyRingCooldown, updateSetting, clearSetting, testDataSourceConnection, } from '../dal' import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types' import type { KeyRingStatusResponse } from '../dal' import SettingRow from '../SettingRow' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' function formatTime(iso: string | null): string { if (!iso) return '暂无记录' try { return new Date(iso).toLocaleString('zh-CN', { hour12: false }) } catch { return iso } } export default function DataSourcesPage() { const [sources, setSources] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState('') const [ingestStats, setIngestStats] = useState>({}) const [ingestLoading, setIngestLoading] = useState(true) const [stats, setStats] = useState(null) const [testingSource, setTestingSource] = useState(null) const [testResults, setTestResults] = useState>({}) const [editingKey, setEditingKey] = useState(null) const [busyKey, setBusyKey] = useState(null) const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null) const [keyRing, setKeyRing] = useState(null) const [ringLoading, setRingLoading] = useState(false) const loadSources = useCallback(async () => { setLoading(true) setLoadError('') try { setSources(await fetchDataSourceStatuses()) } catch (err) { setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败') setSources([]) } finally { setLoading(false) } }, []) // 健康状态(只读,与配置加载并行;失败不阻塞配置页) const loadIngest = useCallback(async () => { setIngestLoading(true) try { const { sources } = await fetchIngestStatus() setIngestStats(Object.fromEntries(sources.map(x => [x.name, x]))) } catch { /* ignore */ } finally { setIngestLoading(false) } }, []) // 管理区统计(只读) const loadStats = useCallback(async () => { try { setStats(await fetchAdminStats()) } catch { /* ignore */ } }, []) // Key Ring 状态(只读) const loadKeyRing = useCallback(async () => { setRingLoading(true) try { setKeyRing(await fetchKeyRingStatus()) } catch { /* ignore */ } finally { setRingLoading(false) } }, []) useEffect(() => { loadSources() loadIngest() loadStats() loadKeyRing() }, [loadSources, loadIngest, loadStats, loadKeyRing]) async function handleTest(sourceName: string) { setTestingSource(sourceName) setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } })) try { const result = await testDataSourceConnection(sourceName) setTestResults(prev => ({ ...prev, [sourceName]: result })) } catch (err: unknown) { const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败' setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } })) } finally { setTestingSource(null) } } // 渲染数据源健康块(最近采集 + 异常提示) function renderHealth(sourceName: string) { const st = ingestStats[sourceName] if (!st) { return (
最近成功采集 {ingestLoading ? '加载中...' : '暂无数据'}
) } const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录' const issues: string[] = [] if (st.status === 'key_not_configured') issues.push('未配置 API Key') else if (st.status === 'no_data') issues.push('本地无数据,建议补采') if (st.last_failure) issues.push('近期有采集失败') return (
最近成功采集 {ago}
已入库(近期) {st.recent_count.toLocaleString()} 条
{st.note &&

{st.note}

} {issues.length > 0 && (

{issues.join(' / ')} — 请前往「数据采集」补采

)} {st.last_failure && (

最近失败: {st.last_failure.detail.slice(0, 60)}

)}
) } async function handleSave(key: string, value: string) { setBusyKey(key) setRowNotice(null) try { await updateSetting(key, value) setRowNotice({ key, ok: true, text: '已保存,立即生效' }) setEditingKey(null) await loadSources() } catch (err) { setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' }) } finally { setBusyKey(null) } } async function handleClear(key: string) { setBusyKey(key) setRowNotice(null) try { await clearSetting(key) setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' }) await loadSources() } catch (err) { setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' }) } finally { setBusyKey(null) } } async function handleResetCooldown() { if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return try { const res = await resetKeyRingCooldown() setKeyRing(res.stats) setRowNotice({ key: "__ring", ok: true, text: res.message }) } catch (err) { setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' }) } } return (
{loadError && ( )} {/* 数据源卡片 */} {loading ? (
{[1, 2, 3].map(i => (
))}
) : (
{sources.map(source => { const result = testResults[source.name] const cardKeys = source.settings.map(s => s.key) return (

{source.label}

{source.key_configured ? '已就绪' : '缺配置'}

{source.description}

{source.settings.length > 0 ? (
{source.settings.map(setting => ( { setEditingKey(setting.key); setRowNotice(null) }} onCancel={() => setEditingKey(null)} onSave={v => handleSave(setting.key, v)} onClear={() => handleClear(setting.key)} /> ))}
) : (

无需 API Key

)} {rowNotice && cardKeys.includes(rowNotice.key) && ( )} {/* 数据源健康:最近采集 + 异常提示 */} {renderHealth(source.name)} {result && testingSource !== source.name && ( )}
) })}
)} {/* API Key 轮换环状态 */} 重置冷却 } /> {ringLoading && !keyRing ? ( ) : keyRing && keyRing.total > 0 ? (
{keyRing.keys.map((k, i) => { const isBlocked = k.blocked_remaining > 0 return (
{k.masked} {i === keyRing.active_index && ( 当前 )}
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
) })}
) : (

暂无 key 配置

)}

在「Bzzoiro」配置项中用逗号 / 分号 / 换行分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key; 全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。

{/* 近期活动统计(只读) */} {stats && stats.predictions && (
{stats.predictions.last_24h}
近 24 小时
{stats.predictions.last_7d}
近 7 天
{stats.predictions.total}
总计
)}

保存的配置存于数据库 app_settings立即生效,优先于 .env;「回落 .env」删除覆盖值。

「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。

「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。

) }