perf+unify: matches 列表查询优化 + Standings 统一数据源

matches 列表:selectinload 加 load_only 限定列(League.code/Team.name,name_zh/
MatchStats.home_xg,away_xg),补 stats 加载消除 N+1(m.stats 此前懒加载)。
详情接口保持完整 options;游标分页/响应字段/空值语义不变。

Standings 改用 useLeagues() 统一数据源(API 优先,失败回退本地常量),
LEAGUES 常量扩展 CL/EL;无数据联赛显示虚线 tab + 空态(非隐藏)。
This commit is contained in:
shangfangjian
2026-09-22 01:35:06 +08:00
parent 92f50fa5d4
commit a00364d4a7
3 changed files with 45 additions and 32 deletions
+16 -17
View File
@@ -8,18 +8,9 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { fetchStandings } from '../admin/dal' import { fetchStandings } from '../admin/dal'
import type { StandingsLeague, StandingRow } from '../admin/dal' import type { StandingsLeague, StandingRow } from '../admin/dal'
import { useLeagues } from './matches/hooks/useLeagues'
import { Spinner } from '../admin/components' import { Spinner } from '../admin/components'
const LEAGUES = [
{ code: 'E0', name: '英超' },
{ code: 'SP1', name: '西甲' },
{ code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' },
{ code: 'CL', name: '欧冠' },
{ code: 'EL', name: '欧联' },
]
const ZONE_META: Record<string, { label: string; cls: string }> = { const ZONE_META: Record<string, { label: string; cls: string }> = {
// 欧战资格 // 欧战资格
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' }, 'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
@@ -64,7 +55,9 @@ function FormDots({ form }: { form?: string | null }) {
} }
export default function StandingsPage() { export default function StandingsPage() {
const [leagues, setLeagues] = useState<StandingsLeague[]>([]) // 统一数据源:复用 useLeagues hook(优先 API,失败回退本地常量)
const leagues = useLeagues()
const [standings, setStandings] = useState<StandingsLeague[]>([])
const [activeLeague, setActiveLeague] = useState<string>('') const [activeLeague, setActiveLeague] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false) // 切换联赛中 const [switching, setSwitching] = useState(false) // 切换联赛中
@@ -85,7 +78,7 @@ export default function StandingsPage() {
setError(null) setError(null)
try { try {
const data = await fetchStandings(code) const data = await fetchStandings(code)
setLeagues(data.leagues) setStandings(data.leagues)
if (!activeLeague && data.leagues.length > 0) { if (!activeLeague && data.leagues.length > 0) {
setActiveLeague(data.leagues[0].league_code) setActiveLeague(data.leagues[0].league_code)
} }
@@ -104,7 +97,7 @@ export default function StandingsPage() {
setSwitching(true) setSwitching(true)
setActiveLeague(code) setActiveLeague(code)
try { try {
await fetchStandings(code).then(data => setLeagues(data.leagues)) await fetchStandings(code).then(data => setStandings(data.leagues))
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败') setError(err instanceof Error ? err.message : '加载失败')
} finally { } finally {
@@ -112,26 +105,32 @@ export default function StandingsPage() {
} }
} }
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0] const active = standings.find(l => l.league_code === activeLeague) ?? standings[0]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 联赛切换 */} {/* 联赛切换 */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{LEAGUES.map(l => ( {leagues.map(l => {
// 标记该联赛是否有积分榜数据:有数据可正常切换,无数据也可选中但显示空态
const hasData = standings.some(s => s.league_code === l.code)
const isEmpty = activeLeague === l.code && !hasData
return (
<button <button
key={l.code} key={l.code}
onClick={() => switchLeague(l.code)} onClick={() => switchLeague(l.code)}
disabled={switching} disabled={switching}
title={hasData ? undefined : '暂无积分榜数据'}
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${ className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
activeLeague === l.code activeLeague === l.code
? 'border-ink-900 bg-ink-900 text-paper-50' ? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-200 text-ink-500 hover:border-ink-300' : 'border-ink-200 text-ink-500 hover:border-ink-300'
}`} } ${!hasData ? 'border-dashed' : ''}`}
> >
{l.name} {l.name}
</button> </button>
))} )
})}
</div> </div>
{error && ( {error && (
+2
View File
@@ -78,6 +78,8 @@ export const LEAGUES = [
{ code: 'D1', name: '德甲' }, { code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' }, { code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' }, { code: 'F1', name: '法甲' },
{ code: 'CL', name: '欧冠' },
{ code: 'EL', name: '欧联' },
] ]
/** 汉字编号,给专家意见排版用 */ /** 汉字编号,给专家意见排版用 */
+16 -4
View File
@@ -5,11 +5,11 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import load_only, selectinload
from src.api.schemas import MatchListOut, MatchOut, PredictionOut from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.db.base import AsyncSession, get_db_read from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match, Prediction, Standing from src.db.models import League, Match, MatchStats, Prediction, Standing, Team
router = APIRouter(prefix="/api/v1", tags=["data"]) router = APIRouter(prefix="/api/v1", tags=["data"])
@@ -49,8 +49,20 @@ async def list_matches(
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db_read), db: AsyncSession = Depends(get_db_read),
): ):
"""比赛列表(游标分页)。""" """比赛列表(游标分页)。
q = select(Match).options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
加载策略(列表 vs 详情):
- 列表:仅 selectinload 序列化需要的 3 个关系 + stats,且用 load_only 限定列
(League.code / Team.name,name_zh / MatchStats.home_xg,away_xg),避免传输全列;
同时一次性加载 stats 消除 N+1(m.stats.home_xg 此前触发懒加载)。
- 详情(/matches/{id}):保持完整 options(league/teams/stats 全列 + 最近预测)。
"""
q = select(Match).options(
selectinload(Match.league).load_only(League.code),
selectinload(Match.home_team).load_only(Team.name, Team.name_zh),
selectinload(Match.away_team).load_only(Team.name, Team.name_zh),
selectinload(Match.stats).load_only(MatchStats.home_xg, MatchStats.away_xg),
)
if cursor: if cursor:
try: try: