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:
@@ -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 && (
|
||||||
|
|||||||
@@ -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: '欧联' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 汉字编号,给专家意见排版用 */
|
/** 汉字编号,给专家意见排版用 */
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user