P0(4): 假数据清除(avg_latency_ms:2400→null,无端点字段改null)、假进度条改诚实的不确定态、 公共页不再反向依赖 admin(api/public.ts)、PredictProgress 重写 P1(6): 23处 any 归零(对齐后端 Pydantic 契约新增 PredictionMatchRef/HealthProbe 等)、 a11y(aria-live 0→4,htmlFor 1→17,aria-describedby/invalid 补齐)、App.tsx 抽 SiteLayout、 路由级 lazy+代码分割(首屏 315KB→238KB)、index.html 补 SEO/favicon/OG、死代码清理(AdminIcon 抽出) P2(7): Login/index.css 裸色值令牌化、groupByDate useMemo、滚动监听统一、原生控件基元化、 useLeagues 静默失败补告警、路由级 ErrorBoundary 另修复审计未列问题: - Monitoring todos 过滤器 t!==false 放行 null 导致整页崩溃 → Boolean(t) 真值过滤 - Collection 渲染期 Date.now()(react-hooks/purity 捕获)→ 计时器 effect - bg-press-wash/60 透明度修饰符静默失效 → RGB 三元组 + 构建期令牌守卫(下个提交接入) 工程化:数据层 dal/api/types(1047行)git mv 至 src/api/,admin 留 @deprecated 兼容壳, 21 个引用方直指新路径;tsconfig 开启 noUnusedLocals/noUnusedParameters(清理9处存量)
224 lines
9.7 KiB
TypeScript
224 lines
9.7 KiB
TypeScript
/**
|
||
* 主站 - 联赛积分榜页
|
||
*
|
||
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||
*/
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
import BackTop from '../components/BackTop'
|
||
import { fetchStandings } from '../api/public'
|
||
import type { StandingsLeague, StandingRow } from '../api/public'
|
||
import { useLeagues } from './matches/hooks/useLeagues'
|
||
import { Spinner, Tabs, Button } from '../components/ui'
|
||
|
||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||
// 欧战资格
|
||
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||
'Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||
'Europa Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||
// 升级
|
||
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||
// 降级
|
||
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-playoff-100 text-playoff-700' },
|
||
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||
// 附加赛
|
||
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-euro-100 text-euro-700' },
|
||
'Qualification': { label: '资格赛', cls: 'bg-euro-100 text-euro-700' },
|
||
}
|
||
|
||
function zoneBadge(zone?: string | null) {
|
||
if (!zone) return null
|
||
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||
return <span className={`whitespace-nowrap px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||
}
|
||
|
||
/** 近期走势串(W/D/L) → 方点(与全站方角语言一致) */
|
||
function FormDots({ form }: { form?: string | null }) {
|
||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||
return (
|
||
<span className="inline-flex gap-0.5">
|
||
{form.slice(0, 5).split('').map((c, i) => (
|
||
<span key={i} className={`inline-block h-1.5 w-1.5 ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export default function StandingsPage() {
|
||
// 统一数据源:复用 useLeagues hook(优先 API,失败回退本地常量)
|
||
const leagues = useLeagues()
|
||
const [standings, setStandings] = useState<StandingsLeague[]>([])
|
||
const [activeLeague, setActiveLeague] = useState<string>('')
|
||
const [loading, setLoading] = useState(true)
|
||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||
const [error, setError] = useState<string | null>(null)
|
||
const load = useCallback(async (code?: string) => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const data = await fetchStandings(code)
|
||
setStandings(data.leagues)
|
||
if (!activeLeague && data.leagues.length > 0) {
|
||
setActiveLeague(data.leagues[0].league_code)
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [activeLeague])
|
||
|
||
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// 切换联赛(带加载态,禁用 tab 防重复点击)
|
||
const switchLeague = async (code: string) => {
|
||
if (code === activeLeague || switching) return
|
||
setSwitching(true)
|
||
setActiveLeague(code)
|
||
try {
|
||
await fetchStandings(code).then(data => setStandings(data.leagues))
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '加载失败')
|
||
} finally {
|
||
setSwitching(false)
|
||
}
|
||
}
|
||
|
||
const active = standings.find(l => l.league_code === activeLeague) ?? standings[0]
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 联赛切换:与首页/后台共用 .tab 语言(方角、宋体、印报红下划线) */}
|
||
<Tabs
|
||
ariaLabel="联赛"
|
||
className="gap-6 border-b border-ink-900"
|
||
value={activeLeague}
|
||
onChange={switchLeague}
|
||
disabled={switching}
|
||
items={leagues.map(l => ({
|
||
value: l.code,
|
||
label: l.name,
|
||
// 无数据也可选中(会显示空态),仅做视觉弱化提示
|
||
empty: !standings.some(s => s.league_code === l.code),
|
||
title: standings.some(s => s.league_code === l.code) ? undefined : '暂无积分榜数据',
|
||
}))}
|
||
/>
|
||
|
||
{error && (
|
||
/* role=alert:错误是异步出现的,需立即被屏幕阅读器朗读 */
|
||
<div className="error-banner" role="alert">
|
||
<div>
|
||
<p className="error-banner-title">请求失败</p>
|
||
<p className="error-banner-detail">{error}</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setError(null)}
|
||
className="!border-transparent !px-1 text-lg leading-none"
|
||
aria-label="关闭错误提示"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{loading && (
|
||
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||
)}
|
||
|
||
{/* 切换联赛时的轻量加载指示 */}
|
||
{switching && !loading && (
|
||
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
|
||
<Spinner /> 切换联赛中…
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !active && (
|
||
<div className="border-y border-ink-200 py-12 text-center">
|
||
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||
<p className="mt-1.5 text-xs text-ink-400">
|
||
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{active && (
|
||
<div>
|
||
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||
<div>
|
||
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||
{active.league_name}
|
||
</h2>
|
||
<p className="text-xs text-ink-400">
|
||
{active.season} 赛季 · {active.rows.length} 队
|
||
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 积分榜表格 */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[640px] text-xs">
|
||
<thead>
|
||
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||
<th className="w-8 py-2 font-medium">#</th>
|
||
<th className="py-2 font-medium">球队</th>
|
||
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||
<th className="w-10 text-center py-2 font-medium">平</th>
|
||
<th className="w-10 text-center py-2 font-medium">负</th>
|
||
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||
<th className="w-12 text-center py-2 font-medium">净</th>
|
||
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||
<th className="w-16 text-right py-2 font-medium">区</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{active.rows.map((r: StandingRow) => (
|
||
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
|
||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||
</td>
|
||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||
<td className="text-center py-2 text-ink-500">
|
||
{r.xg_for != null && r.xg_against != null
|
||
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 回到顶部(共享组件,方角纸片风) */}
|
||
<BackTop />
|
||
</div>
|
||
)
|
||
}
|