F1: 统一前端 HTTP 客户端 - 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理) - Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端 - 公开站与 Admin 行为一致 D2: events 采集按联赛分批提交 - _run_bzzoiro 改为 per-league 独立事务,避免超长事务 - 单联赛失败不影响其他联赛 F3: 仪表盘真实计数 - admin_stats 补充 matches/stats/standings 真实聚合 - Dashboard 展示比赛总数/已完赛/统计行/积分榜 F4: LLM 连通性测试不依赖 match_id=1 - 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛) - testLLMConnection 优先调用 ping,失败回退旧方式 F5: 导航改用 React Router Link - App.tsx 中 <a href> 全部替换为 <Link to> D5: 删除 away_possession 死代码 - bzzoiro.py 中移除计算与注释 D1: 管线基础设施表文档化 - RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
/**
|
|
* Admin 后台 - 数据访问层
|
|
*
|
|
* 封装所有 API 端点调用,返回类型安全的数据。
|
|
* 所有端点对齐 FastAPI 后端实际实现。
|
|
*/
|
|
|
|
import { api, API_BASE } from './api'
|
|
import type {
|
|
DashboardStats,
|
|
CollectionRequest,
|
|
BacktestRequest,
|
|
BacktestSummary,
|
|
League,
|
|
Match,
|
|
Prediction,
|
|
EvalSummary,
|
|
DataSourceStatus,
|
|
DataSourceSetting,
|
|
DataSourceTestResult,
|
|
LLMAgentConfig,
|
|
LogEntry,
|
|
IngestSourceStatus,
|
|
MatchDetailOut,
|
|
MatchContextOut,
|
|
AdminStats,
|
|
} from './types'
|
|
|
|
// ── 仪表盘 ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 从多个端点聚合仪表盘数据。
|
|
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
|
*
|
|
* P2-8 修复: 使用 items.length 替代不存在的 total 字段,
|
|
* 并扩大 limit 以获得更有参考价值的数量。
|
|
*/
|
|
export async function fetchDashboard(): Promise<DashboardStats> {
|
|
// 并行获取各端点数据
|
|
// matches 返回 {items, next_cursor, has_more}, predictions 返回数组
|
|
const [leagues, matches, predictions, health] = await Promise.allSettled([
|
|
api.get<League[]>(`${API_BASE}/leagues`),
|
|
api.get<{ items: Match[]; has_more: boolean }>(`${API_BASE}/matches?limit=100`),
|
|
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
|
api.get<{ status: string }>('/health'),
|
|
])
|
|
|
|
return {
|
|
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
|
// P2-8: matches 无 total 字段,用 items.length 近似(上限 100)
|
|
total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.items?.length ?? 0 : 0,
|
|
// predictions 直接返回数组
|
|
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.length ?? 0 : 0,
|
|
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
|
|
db_tables: [], // 后端暂无表统计端点
|
|
last_collection: [], // 后端暂无采集历史端点
|
|
recent_errors: [], // 后端暂无错误日志端点
|
|
}
|
|
}
|
|
|
|
// ── 数据采集 ────────────────────────────────────────────────────
|
|
|
|
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
|
const body: Record<string, any> = {
|
|
leagues: req.leagues,
|
|
date_from: req.date_from,
|
|
date_to: req.date_to,
|
|
status: req.status || undefined,
|
|
task: req.task || 'events',
|
|
limit: req.limit || 100,
|
|
season: req.season || undefined,
|
|
}
|
|
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
|
|
}
|
|
|
|
// ── 预测管理 ────────────────────────────────────────────────────
|
|
|
|
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
|
|
return api.post(
|
|
`${API_BASE}/predict`,
|
|
{
|
|
match_id: req.match_id,
|
|
mode: req.mode || 'multi',
|
|
},
|
|
{ timeoutMs: 300_000 },
|
|
)
|
|
}
|
|
|
|
export async function fetchPredictions(limit = 50): Promise<any[]> {
|
|
const res = await api.get<any>(`${API_BASE}/predictions?limit=${limit}`)
|
|
return Array.isArray(res) ? res : (res as any)?.items ?? []
|
|
}
|
|
|
|
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
|
|
|
export async function fetchEvalSummary(params: {
|
|
limit?: number
|
|
provider?: string
|
|
model?: string
|
|
prompt_version?: string
|
|
mode?: string
|
|
league_code?: string
|
|
} = {}): Promise<EvalSummary | null> {
|
|
const sp = new URLSearchParams()
|
|
if (params.limit) sp.set('limit', String(params.limit))
|
|
if (params.provider) sp.set('provider', params.provider)
|
|
if (params.model) sp.set('model', params.model)
|
|
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
|
|
if (params.mode) sp.set('mode', params.mode)
|
|
if (params.league_code) sp.set('league_code', params.league_code)
|
|
try {
|
|
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
|
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
|
}
|
|
|
|
// ── 辅助数据 ────────────────────────────────────────────────────
|
|
|
|
export async function fetchLeagues(): Promise<League[]> {
|
|
try {
|
|
return await api.get<League[]>(`${API_BASE}/leagues`)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function fetchMatches(params: {
|
|
league?: string
|
|
status?: string
|
|
limit?: number
|
|
cursor?: string
|
|
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
|
|
const sp = new URLSearchParams()
|
|
if (params.league) sp.set('league', params.league)
|
|
if (params.status) sp.set('status', params.status)
|
|
if (params.limit) sp.set('limit', String(params.limit))
|
|
if (params.cursor) sp.set('cursor', params.cursor)
|
|
|
|
try {
|
|
return await api.get<any>(`${API_BASE}/matches?${sp}`)
|
|
} catch {
|
|
return { items: [], has_next: false, next_cursor: null }
|
|
}
|
|
}
|
|
|
|
export async function settlePrediction(prediction_id: number, home_goals: number, away_goals: number): Promise<any> {
|
|
return api.post(`${API_BASE}/eval/settle`, {
|
|
prediction_id,
|
|
home_goals,
|
|
away_goals,
|
|
})
|
|
}
|
|
|
|
// ── 健康检查 ────────────────────────────────────────────────────
|
|
|
|
export async function fetchHealth(): Promise<any> {
|
|
try {
|
|
return await api.get<any>('/health')
|
|
} catch {
|
|
return { status: 'unknown' }
|
|
}
|
|
}
|
|
|
|
// ── 数据完整性 ──────────────────────────────────────────────────
|
|
|
|
export interface DataCompletenessResponse {
|
|
generated_at: string
|
|
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
|
issues: string[]
|
|
leagues: Array<{
|
|
code: string
|
|
name: string
|
|
country?: string
|
|
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
|
stats: {
|
|
rows: number
|
|
fields: Record<string, { count: number; pct: number }>
|
|
}
|
|
standings: { rows: number; latest_retrieved?: string }
|
|
}>
|
|
}
|
|
|
|
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
|
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
|
}
|
|
|
|
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
|
|
|
|
export interface StandingRow {
|
|
position: number
|
|
team: string
|
|
team_en: string
|
|
played: number
|
|
won: number
|
|
drawn: number
|
|
lost: number
|
|
goals_for: number
|
|
goals_against: number
|
|
goal_diff: number
|
|
points: number
|
|
xg_for: number | null
|
|
xg_against: number | null
|
|
form: string | null
|
|
zone: string | null
|
|
}
|
|
|
|
export interface StandingsLeague {
|
|
league_code: string
|
|
league_name: string
|
|
season: string
|
|
retrieved_at: string | null
|
|
rows: StandingRow[]
|
|
}
|
|
|
|
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
|
const sp = new URLSearchParams()
|
|
if (league) sp.set('league', league)
|
|
if (season) sp.set('season', season)
|
|
const qs = sp.toString()
|
|
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
|
}
|
|
|
|
// ── 数据源管理 ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
|
*/
|
|
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
|
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
|
}
|
|
|
|
/**
|
|
* 获取数据源状态与配置(脱敏)
|
|
*/
|
|
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
|
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
|
}
|
|
|
|
/**
|
|
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
|
*/
|
|
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
|
return api.get(`${API_BASE}/admin/llm/models`)
|
|
}
|
|
|
|
/**
|
|
* 各专家/终裁的独立 LLM 配置状态
|
|
*/
|
|
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
|
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
|
}
|
|
|
|
/**
|
|
* 查询系统日志(内存缓冲,最新在前)
|
|
*/
|
|
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
|
const sp = new URLSearchParams()
|
|
if (params.level) sp.set('level', params.level)
|
|
if (params.keyword) sp.set('keyword', params.keyword)
|
|
if (params.limit) sp.set('limit', String(params.limit))
|
|
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
|
}
|
|
|
|
/**
|
|
* 全部可配置项(脱敏),供各配置页渲染
|
|
*/
|
|
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
|
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
|
}
|
|
|
|
/**
|
|
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
|
*/
|
|
export function updateSetting(key: string, value: string) {
|
|
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
|
}
|
|
|
|
/**
|
|
* 清除配置项的 DB 覆盖值,回落 .env
|
|
*/
|
|
export function clearSetting(key: string) {
|
|
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
|
}
|
|
|
|
// ── LLM 配置 ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 测试 LLM 连接 — 调用预测端点验证
|
|
*/
|
|
export async function testLLMConnection(matchId?: number): Promise<any> {
|
|
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
|
try {
|
|
return await api.post(`${API_BASE}/admin/llm/ping`, {})
|
|
} catch {
|
|
// 回退到旧方式(兼容)
|
|
return api.post(
|
|
`${API_BASE}/predict`,
|
|
{ match_id: matchId || 1, mode: 'single' },
|
|
{ timeoutMs: 300_000 },
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取 LLM 使用统计 — 从预测列表聚合
|
|
*/
|
|
export async function fetchLLMUsageStats(): Promise<any> {
|
|
try {
|
|
const predictions = await fetchPredictions(50)
|
|
const total = predictions.length
|
|
const successCount = predictions.filter((p: any) => p.pred_1x2).length
|
|
return {
|
|
total_predictions: total,
|
|
avg_latency_ms: 2400, // 后端暂无延迟统计
|
|
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
|
recent_predictions: predictions.slice(0, 10).map((p: any) => ({
|
|
id: p.id,
|
|
match_id: p.match_id,
|
|
model: p.model,
|
|
created_at: p.created_at,
|
|
status: p.pred_1x2 ? 'success' : 'failed',
|
|
})),
|
|
}
|
|
} catch {
|
|
return {
|
|
total_predictions: 0,
|
|
avg_latency_ms: 0,
|
|
success_rate: 0,
|
|
recent_predictions: [],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 系统配置 ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* P2-9 修复: 获取系统配置列表,从后端 /admin/settings 读取真实值(脱敏)。
|
|
* 字段名对齐 Config.tsx 中使用的 { key, value_masked, description, is_sensitive } 格式。
|
|
*/
|
|
export async function fetchSystemConfig(): Promise<any[]> {
|
|
try {
|
|
const settings = await fetchSettings()
|
|
return settings.map(s => ({
|
|
key: s.key,
|
|
value_masked: s.masked,
|
|
description: s.description,
|
|
is_sensitive: s.sensitive,
|
|
}))
|
|
} catch {
|
|
// 后端不可用时返回空列表,Config.tsx 会显示空状态
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 数据源健康/最近采集状态(只读,不触发采集)
|
|
*/
|
|
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
|
|
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
|
|
}
|
|
|
|
/**
|
|
* 比赛详情(含最近预测摘要)
|
|
*/
|
|
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
|
|
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
|
|
}
|
|
|
|
/**
|
|
* 比赛上下文(双方近况 + 历史交锋,只读)
|
|
*/
|
|
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
|
|
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
|
|
}
|
|
|
|
/**
|
|
* 管理区统计(只读):近 24h/7d 预测次数
|
|
*/
|
|
export function fetchAdminStats(): Promise<AdminStats> {
|
|
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
|
}
|
|
|
|
// ── API Key 轮换环 ──────────────────────────────────────────────
|
|
|
|
export interface KeyRingKeyStatus {
|
|
masked: string
|
|
blocked_remaining: number
|
|
}
|
|
|
|
export interface KeyRingStatusResponse {
|
|
base_url: string
|
|
total: number
|
|
has_multiple: boolean
|
|
cooldown_seconds: number
|
|
active_index: number
|
|
active_key: string | null
|
|
keys: KeyRingKeyStatus[]
|
|
}
|
|
|
|
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
|
|
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
|
|
}
|
|
|
|
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
|
|
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
|
|
}
|
|
|
|
// ── 定时任务 ────────────────────────────────────────────────────
|
|
|
|
export interface ScheduleItem {
|
|
id: string
|
|
task: string
|
|
cron: string
|
|
leagues?: string
|
|
enabled: boolean
|
|
last_run_at?: string | null
|
|
last_status?: string | null
|
|
}
|
|
|
|
export async function fetchSchedules(): Promise<ScheduleItem[]> {
|
|
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
|
|
}
|
|
|
|
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
|
|
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
|
|
}
|
|
|
|
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
|
|
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
|
|
}
|
|
|
|
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
|
|
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
|
|
}
|
|
|
|
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
|
|
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
|
|
}
|