feat(api): 比赛上下文与联赛列表改公开只读(P1-2/P1-3)

- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
  数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
  id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
  本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
  路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
  (ingest 路由必须检出 require_admin,防检测器恒真)
This commit is contained in:
WorkBuddy
2026-09-21 20:55:26 +08:00
parent 3056a95ef4
commit a24017eb69
4 changed files with 211 additions and 7 deletions
+6 -3
View File
@@ -15,13 +15,16 @@ import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
import { useMatchesList } from './matches/hooks/useMatchesList'
import { useMatchPredict } from './matches/hooks/useMatchPredict'
import { useLeagues } from './matches/hooks/useLeagues'
import { PredictModal } from './matches/components/MatchPredictPanel'
import { MatchRow } from './matches/components/MatchDetailSection'
import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui'
import { LEAGUES, type Match } from './matches/types'
import { type Match } from './matches/types'
export default function Matches() {
const [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
// P1-3: 联赛列表优先请求 /api/v1/leagues,失败/空回退本地五大联赛常量
const leagues = useLeagues()
const {
league, setLeague,
status, setStatus,
@@ -58,7 +61,7 @@ export default function Matches() {
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
const leagueName = leagues.find(l => l.code === league)?.name ?? league
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled'
@@ -91,7 +94,7 @@ export default function Matches() {
<div className="space-y-5">
{/* ── 联赛版面切换 ── */}
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
{LEAGUES.map(l => (
{leagues.map(l => (
<button
key={l.code}
onClick={() => setLeague(l.code)}
@@ -0,0 +1,38 @@
/**
* 公开站联赛列表(P1-3):优先请求 GET /api/v1/leagues,
* 失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
*
* 显示名规则:常量里已有的 code 沿用中文标签(保持现有 UI 语言不变),
* 新增联赛用 API 返回的 name;排序按常量顺序优先、新联赛按 API 返回序追加。
* API 仅返回 {id, code, name, country},无敏感配置字段。
*/
import { useEffect, useState } from 'react'
import { fetchLeagues } from '../../../admin/dal'
import { LEAGUES } from '../types'
export function useLeagues(): { code: string; name: string }[] {
const [leagues, setLeagues] = useState(LEAGUES)
useEffect(() => {
let alive = true
;(async () => {
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
const rows = await fetchLeagues()
if (!alive || rows.length === 0) return
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
const merged = rows
.slice()
.sort(
(a, b) => (rank.get(a.code) ?? LEAGUES.length) - (rank.get(b.code) ?? LEAGUES.length),
)
.map(l => ({ code: l.code, name: zhName.get(l.code) ?? l.name }))
setLeagues(merged)
})()
return () => {
alive = false
}
}, [])
return leagues
}