P0 严重问题: - 采集任务进度反馈:Collection 页新增任务状态跟踪(运行中/完成/失败) + 轮询 ingest 状态 - 积分榜加载态:切换联赛显示 spinner + 禁用 tab 防重复点击 - 数据完整性可操作:问题列表每项加「去修复」按钮,跳转采集页并预填参数 - 面包屑导航:顶部显示 仪表盘 > 当前页 P1 重要问题: - 侧边栏当前页指示器:1.5px 圆点 → 4px 左侧彩色竖条 + 背景高亮 - Key Ring 重置确认:点击「重置冷却」前弹窗确认 - 预测比赛选择器:只显示未开赛 + 联赛筛选 + 显示可预测数量 - 表格移动端溢出:评估页表格加 overflow-x-auto - 设置页合并:数据源 + LLM + 系统配置 → 统一「设置」页 - 预测按钮去重:移动端/桌面端合并为一个响应式按钮 P2 体验优化: - 日志滚动保持:自动滚动仅当用户未向上滚动时 - 评估结果预览:显示「共 N 组」 - ⌘K 命令面板:键盘导航跳转所有管理页面 - 专家意见折叠:默认折叠,可展开(已存在) - 版本信息:侧边栏底部显示 v1.0 - 积分榜表格:min-w 防止挤压 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
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<DataSourceStatus[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadError, setLoadError] = useState('')
|
|
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
|
|
const [ingestLoading, setIngestLoading] = useState(true)
|
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
|
|
|
const [testingSource, setTestingSource] = useState<string | null>(null)
|
|
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
|
|
|
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
|
const [busyKey, setBusyKey] = useState<string | null>(null)
|
|
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
|
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(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 (
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-ink-400">最近成功采集</span>
|
|
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
|
|
</div>
|
|
)
|
|
}
|
|
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 (
|
|
<div className="space-y-1.5 border-t border-ink-200 pt-3">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-ink-400">最近成功采集</span>
|
|
<span className="text-ink-600">{ago}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-ink-400">已入库(近期)</span>
|
|
<span className="text-ink-600">{st.recent_count.toLocaleString()} 条</span>
|
|
</div>
|
|
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
|
|
{issues.length > 0 && (
|
|
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
|
|
{issues.join(' / ')} — 请前往「数据采集」补采
|
|
</p>
|
|
)}
|
|
{st.last_failure && (
|
|
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
|
|
最近失败: {st.last_failure.detail.slice(0, 60)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="数据源管理"
|
|
description="数据采集源的 API 配置、健康状态与连通性测试。"
|
|
/>
|
|
|
|
{loadError && (
|
|
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
|
)}
|
|
|
|
{/* 数据源卡片 */}
|
|
{loading ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{[1, 2, 3].map(i => (
|
|
<Card key={i}>
|
|
<CardBody>
|
|
<div className="space-y-3">
|
|
<SkeletonBlock className="h-4 w-24" />
|
|
<SkeletonBlock className="h-3 w-32" />
|
|
<SkeletonBlock className="h-8 w-full" />
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{sources.map(source => {
|
|
const result = testResults[source.name]
|
|
const cardKeys = source.settings.map(s => s.key)
|
|
return (
|
|
<Card key={source.name}>
|
|
<CardBody className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
|
|
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
|
|
<Badge status={source.key_configured ? 'success' : 'error'}>
|
|
{source.key_configured ? '已就绪' : '缺配置'}
|
|
</Badge>
|
|
</div>
|
|
|
|
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
|
|
|
{source.settings.length > 0 ? (
|
|
<div>
|
|
{source.settings.map(setting => (
|
|
<SettingRow
|
|
key={setting.key}
|
|
setting={setting}
|
|
editing={editingKey === setting.key}
|
|
busy={busyKey === setting.key}
|
|
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
|
onCancel={() => setEditingKey(null)}
|
|
onSave={v => handleSave(setting.key, v)}
|
|
onClear={() => handleClear(setting.key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-2xs text-ink-400">无需 API Key</p>
|
|
)}
|
|
|
|
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
|
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
|
)}
|
|
|
|
{/* 数据源健康:最近采集 + 异常提示 */}
|
|
{renderHealth(source.name)}
|
|
|
|
{result && testingSource !== source.name && (
|
|
<Alert
|
|
kind={result.ok ? 'ok' : 'error'}
|
|
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
|
|
message={result.ok ? undefined : result.detail}
|
|
/>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => handleTest(source.name)}
|
|
disabled={testingSource === source.name}
|
|
className="btn btn-sm w-full"
|
|
>
|
|
{testingSource === source.name ? (<><Spinner /> 测试中</>) : '测试连接'}
|
|
</button>
|
|
</CardBody>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* API Key 轮换环状态 */}
|
|
<Card>
|
|
<CardHeader
|
|
title="API Key 轮换环"
|
|
description={keyRing?.has_multiple
|
|
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
|
|
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
|
|
}
|
|
action={
|
|
<button
|
|
onClick={handleResetCooldown}
|
|
disabled={ringLoading}
|
|
className="btn-sm btn-outline"
|
|
>
|
|
重置冷却
|
|
</button>
|
|
}
|
|
/>
|
|
<CardBody>
|
|
{ringLoading && !keyRing ? (
|
|
<SkeletonBlock className="h-10 w-full" />
|
|
) : keyRing && keyRing.total > 0 ? (
|
|
<div className="space-y-2">
|
|
{keyRing.keys.map((k, i) => {
|
|
const isBlocked = k.blocked_remaining > 0
|
|
return (
|
|
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
|
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
|
{i === keyRing.active_index && (
|
|
<span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>
|
|
)}
|
|
</div>
|
|
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
|
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
|
)}
|
|
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
|
在「Bzzoiro」配置项中用<b>逗号 / 分号 / 换行</b>分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key;
|
|
全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 近期活动统计(只读) */}
|
|
{stats && stats.predictions && (
|
|
<Card>
|
|
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
|
|
<CardBody>
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div>
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
|
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
|
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
|
<div className="mt-1 text-2xs text-ink-400">总计</div>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader title="配置说明" />
|
|
<CardBody>
|
|
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
|
|
<p className="border-l-2 border-ink-300 pl-3">
|
|
保存的配置存于数据库 <code className="font-mono">app_settings</code> 并<b>立即生效</b>,优先于 <code className="font-mono">.env</code>;「回落 .env」删除覆盖值。
|
|
</p>
|
|
<p className="border-l-2 border-ink-300 pl-3">
|
|
「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。
|
|
</p>
|
|
<p className="border-l-2 border-ink-300 pl-3">
|
|
「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。
|
|
</p>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|