- scrollIntoView 改用 requestAnimationFrame 确保 DOM 已更新 - 滚动位置改为 block: 'start',更可靠 - 高亮增加背景色变化(bg-press-wash/40),更明显 - 高亮持续时间延长至 5 秒 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>> )
242 lines
9.7 KiB
TypeScript
242 lines
9.7 KiB
TypeScript
/**
|
|
* Admin 后台 - 数据完整性分析页
|
|
*
|
|
* 回答三个问题:
|
|
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
|
* 2. 字段是否齐全(每张统计表各字段非空率)
|
|
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
import { fetchDataCompleteness } from '../dal'
|
|
import type { DataCompletenessResponse } from '../dal'
|
|
import {
|
|
Card, CardBody, CardHeader, SectionHeader, Alert,
|
|
ProgressBar, Spinner, EmptyState,
|
|
} from '../components'
|
|
|
|
const FIELD_LABELS: Record<string, string> = {
|
|
xg: 'xG 预期进球',
|
|
shots: '射门',
|
|
possession: '控球率',
|
|
corners: '角球',
|
|
fouls: '犯规',
|
|
big_chances: '绝佳机会',
|
|
cards: '红黄牌',
|
|
}
|
|
|
|
function pctColor(pct: number): string {
|
|
if (pct >= 80) return 'bg-emerald-500'
|
|
if (pct >= 50) return 'bg-amber-500'
|
|
return 'bg-rose-500'
|
|
}
|
|
|
|
/** 根据问题描述生成可操作的修复链接 */
|
|
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
|
|
// 提取联赛代码(大写字母+数字,如 E0, SP1)
|
|
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
|
|
const code = codeMatch ? codeMatch[1] : ''
|
|
if (!code) return null
|
|
|
|
if (issue.includes('无已完赛比赛')) {
|
|
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
|
|
}
|
|
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
|
|
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
|
|
}
|
|
if (issue.includes('无积分榜')) {
|
|
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
|
|
}
|
|
return null
|
|
}
|
|
|
|
export default function DataCompletenessPage() {
|
|
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
|
|
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const d = await fetchDataCompleteness()
|
|
setData(d)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : '加载失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
|
const scrollToLeague = useCallback((code: string) => {
|
|
setHighlightedLeague(code)
|
|
// 使用 requestAnimationFrame 确保 DOM 已更新
|
|
requestAnimationFrame(() => {
|
|
const el = leagueRefs.current[code]
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
})
|
|
// 5秒后移除高亮
|
|
setTimeout(() => setHighlightedLeague(null), 5000)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="数据完整性"
|
|
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
|
action={
|
|
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
|
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
|
|
|
{loading && !data && (
|
|
<div className="flex justify-center py-12"><Spinner /></div>
|
|
)}
|
|
|
|
{data && (
|
|
<>
|
|
{/* 全局概览 */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<Card>
|
|
<CardBody className="text-center">
|
|
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
|
|
<p className="text-xs text-ink-500">已完赛比赛(总计)</p>
|
|
</CardBody>
|
|
</Card>
|
|
<Card>
|
|
<CardBody className="text-center">
|
|
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
|
|
<p className="text-xs text-ink-500">统计行数</p>
|
|
</CardBody>
|
|
</Card>
|
|
<Card>
|
|
<CardBody className="text-center">
|
|
<p className="text-2xl font-bold text-ink-900">
|
|
{data.totals.stats_coverage_pct}%
|
|
</p>
|
|
<p className="text-xs text-ink-500">统计覆盖率(有统计 / 已完赛)</p>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 健康问题(可操作) */}
|
|
<Card>
|
|
<CardHeader title="健康摘要" />
|
|
<CardBody>
|
|
<div className="space-y-2">
|
|
{data.issues.map((issue, i) => {
|
|
const action = getIssueAction(issue)
|
|
return (
|
|
<Alert
|
|
key={i}
|
|
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
|
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
|
message={issue}
|
|
action={action ? (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => scrollToLeague(action.code)}
|
|
className="btn btn-sm whitespace-nowrap"
|
|
>
|
|
定位
|
|
</button>
|
|
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
|
{action.label}
|
|
</a>
|
|
</div>
|
|
) : undefined}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 各联赛详情 */}
|
|
<div className="space-y-4">
|
|
{data.leagues.map(league => {
|
|
const finished = league.matches.finished
|
|
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
|
|
const isHighlighted = highlightedLeague === league.code
|
|
return (
|
|
<div
|
|
key={league.code}
|
|
ref={el => { leagueRefs.current[league.code] = el }}
|
|
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
|
|
>
|
|
<Card>
|
|
<CardHeader
|
|
title={league.name}
|
|
description={[
|
|
league.country,
|
|
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled} 场`,
|
|
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
|
|
league.standings.rows > 0 ? `积分榜 ${league.standings.rows} 队` : '',
|
|
].filter(Boolean).join(' · ')}
|
|
/>
|
|
<CardBody className="space-y-4">
|
|
{/* 统计覆盖率进度条 */}
|
|
<div>
|
|
<div className="mb-1 flex items-center justify-between text-xs">
|
|
<span className="text-ink-600">统计回填覆盖率</span>
|
|
<span className="font-medium text-ink-900">
|
|
{league.stats.rows} / {finished} ({statsPct}%)
|
|
</span>
|
|
</div>
|
|
<ProgressBar value={statsPct} />
|
|
</div>
|
|
|
|
{/* 字段覆盖率矩阵 */}
|
|
{league.stats.rows > 0 ? (
|
|
<div>
|
|
<p className="mb-2 text-xs font-medium text-ink-600">字段覆盖率(有值行数 / 总行数)</p>
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
{Object.entries(league.stats.fields).map(([key, info]) => (
|
|
<div key={key} className="rounded border border-ink-100 px-3 py-2">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
|
|
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
|
|
<div
|
|
className={`h-full rounded-full ${pctColor(info.pct)}`}
|
|
style={{ width: `${info.pct}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} 行</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : finished > 0 ? (
|
|
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
|
|
) : (
|
|
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<p className="text-center text-2xs text-ink-400">
|
|
生成时间: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|