Files
Profeto/frontend/src/admin/pages/DataSources.tsx
T
shangfangjianandnew-provider/LongCat-2.0 < fb27f6e2d2 UI/UX 全面改进:22项优化落地(报纸风前端 + 管理后台)
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>>
2026-09-20 21:24:21 +08:00

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>
)
}