Files
Profeto/frontend/src/pages/Standings.tsx
T
shangfangjian f71f29b4cb fix(frontend): 按审计报告修复全部17项问题,数据层迁至 src/api/
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处存量)
2026-09-22 23:45:06 +08:00

224 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 主站 - 联赛积分榜页
*
* 展示各联赛最新积分榜(位置/积分/净胜/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>
)
}