diff --git a/frontend/index.html b/frontend/index.html index 36ba107..87d864a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,46 @@
+
{error}
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。 diff --git a/frontend/src/admin/SettingRow.tsx b/frontend/src/admin/SettingRow.tsx index f1198c6..e76cd15 100644 --- a/frontend/src/admin/SettingRow.tsx +++ b/frontend/src/admin/SettingRow.tsx @@ -7,8 +7,9 @@ */ import { useEffect, useState } from 'react' -import type { DataSourceSetting } from './types' +import type { DataSourceSetting } from '../api/types' import { Badge, Spinner } from './components' +import { Button, Input } from '../components/ui' export const ORIGIN_BADGE: Record = { db: { text: '数据库覆盖', status: 'success' }, @@ -74,34 +75,32 @@ export default function SettingRow({ {setting.sensitive && 敏感} - setValue(e.target.value)} placeholder={`输入新的 ${setting.label}`} + /* 无可见 label(标题即配置项名),用 aria-label 提供可访问名称 */ + aria-label={`设置项 ${setting.label} 的值`} autoFocus autoComplete="off" - className="field flex-1" + className="flex-1" /> - onSave(value)} - disabled={!value.trim() || busy} - className="btn btn-solid btn-sm" - > + onSave(value)} disabled={!value.trim() || busy}> {busy ? (<> 保存中>) : '保存'} - - + + 取消 - + {detectModels && ( - + {detecting ? (<> 检测中>) : detected ? '重新检测' : '检测可用模型'} - + {detectError && ( @@ -146,13 +145,13 @@ export default function SettingRow({ {setting.configured ? setting.masked : '—'} - + {setting.configured ? '更换' : '配置'} - + {setting.origin === 'db' && ( - + 回落 .env - + )} diff --git a/frontend/src/admin/api.ts b/frontend/src/admin/api.ts index 07d37ab..07f8696 100644 --- a/frontend/src/admin/api.ts +++ b/frontend/src/admin/api.ts @@ -1,66 +1,5 @@ /** - * Admin 后台管理系统 - 统一 API 客户端(门面) - * - * 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话, - * 同源请求自动携带 Cookie,无需手动管理密钥。 - * 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。 - * - * 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份), - * 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。 + * 【兼容壳 · 已废弃】实现已迁至 `src/api/api.ts`。 + * 新代码请直接从 `../api/api` 导入。 */ - -import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http' - -/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */ -export { ApiError, UNAUTHORIZED_EVENT } - -const API_BASE = '/api/v1' - -/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */ -type ApiOpts = { - timeoutMs?: number - /** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */ - skipAuthHandling?: boolean -} - -export const api = { - get: (path: string) => http.get(path), - post: (path: string, body?: unknown, opts?: ApiOpts) => - http.post(path, body, opts), - put: (path: string, body?: unknown, opts?: ApiOpts) => - http.put(path, body, opts), - delete: (path: string) => http.delete(path), -} - -// ── 认证 ──────────────────────────────────────────────────────── - -/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */ -export function login(password: string): Promise<{ ok: boolean }> { - return api.post(`${API_BASE}/auth/login`, { password }) -} - -/** 退出登录,清除会话 Cookie */ -export function logout(): Promise<{ ok: boolean }> { - return api.post(`${API_BASE}/auth/logout`) -} - -/** 探测当前登录状态 */ -export function fetchAuthState(): Promise<{ - authenticated: boolean - enabled: boolean - password_origin?: 'db' | 'env' | 'none' -}> { - return api.get(`${API_BASE}/auth/me`) -} - -/** 修改管理员密码(成功后所有会话失效,需重新登录) */ -export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> { - // skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出 - return api.post( - `${API_BASE}/auth/change-password`, - { current_password: currentPassword, new_password: newPassword }, - { skipAuthHandling: true }, - ) -} - -export { API_BASE } +export * from '../api/api' diff --git a/frontend/src/admin/dal.ts b/frontend/src/admin/dal.ts index f605f2b..b07dd42 100644 --- a/frontend/src/admin/dal.ts +++ b/frontend/src/admin/dal.ts @@ -1,519 +1,8 @@ /** - * Admin 后台 - 数据访问层 + * 【兼容壳 · 已废弃】实现已迁至 `src/api/dal.ts`。 * - * 封装所有 API 端点调用,返回类型安全的数据。 - * 所有端点对齐 FastAPI 后端实际实现。 + * 迁移原因见 `src/api/public.ts` 头注释:admin 目录不应承载被前台 + * 反向依赖的数据层。本文件仅为不破坏旧 import 路径而保留, + * **新代码请直接从 `../api/dal`(admin 内)或 `../api`(其余位置)导入。* */ - -import { api, API_BASE } from './api' -import type { - DashboardStats, - CollectionRequest, - BacktestRequest, - BacktestSummary, - League, - Match, - Prediction, - EvalSummary, - DataSourceStatus, - DataSourceSetting, - DataSourceTestResult, - LLMAgentConfig, - LogEntry, - IngestSourceStatus, - IngestJob, - MatchDetailOut, - MatchContextOut, - AdminStats, -} from './types' - -// ── 仪表盘 ────────────────────────────────────────────────────── - -/** - * 从多个端点聚合仪表盘数据。 - * 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。 - * - * 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats, - * 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似, - * 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。 - */ -export async function fetchDashboard(): Promise { - // 并行获取各端点数据 - // predictions 返回数组 - const [leagues, predictions, health] = await Promise.allSettled([ - api.get(`${API_BASE}/leagues`), - api.get(`${API_BASE}/predictions?limit=100`), - api.get<{ status: string }>('/health'), - ]) - - return { - leagues: leagues.status === 'fulfilled' ? leagues.value : [], - // 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 { - const body: Record = { - 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 { - 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 { - const res = await api.get(`${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 { - 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(`${API_BASE}/eval/summary?${sp}`) - } catch { - return null - } -} - -export async function triggerBacktest(req: BacktestRequest): Promise { - return api.post(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 }) -} - -// ── 辅助数据 ──────────────────────────────────────────────────── - -export async function fetchLeagues(): Promise { - try { - return await api.get(`${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(`${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 { - return api.post(`${API_BASE}/eval/settle`, { - prediction_id, - home_goals, - away_goals, - }) -} - -// ── 健康检查 ──────────────────────────────────────────────────── - -export async function fetchHealth(): Promise { - try { - return await api.get('/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 - } - standings: { rows: number; latest_retrieved?: string } - }> -} - -export async function fetchDataCompleteness(): Promise { - return api.get(`${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 { - return api.post(`${API_BASE}/admin/datasources/${name}/test`) -} - -/** - * 获取数据源状态与配置(脱敏) - */ -export function fetchDataSourceStatuses(): Promise { - return api.get(`${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 { - return api.get(`${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 { - return api.get(`${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 { - // 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 { - 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 { - 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`) -} - -/** - * 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额 - */ -export function fetchUpstreamProbe(): Promise<{ - ok: boolean - status_code?: number - latency_ms: number - endpoint: string - error?: string -}> { - return api.get(`${API_BASE}/admin/monitoring/upstream`) -} - -/** - * 采集任务状态轮询(单任务) - */ -export function fetchIngestJob(jobId: string): Promise { - return api.get(`${API_BASE}/admin/ingest/jobs/${jobId}`) -} - -/** - * 采集任务历史列表(GET /admin/ingest/jobs,最新在前) - */ -export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise { - const q = new URLSearchParams() - if (params.limit != null) q.set('limit', String(params.limit)) - if (params.status) q.set('status', params.status) - const qs = q.toString() - return api.get(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`) -} - -/** - * 比赛详情(含最近预测摘要) - */ -export function fetchMatchDetail(id: number): Promise { - return api.get(`${API_BASE}/matches/${id}`) -} - -/** - * 比赛上下文(双方近况 + 历史交锋,只读) - */ -export function fetchMatchContext(id: number): Promise { - return api.get(`${API_BASE}/matches/${id}/context`) -} - -/** - * 管理区统计(只读):近 24h/7d 预测次数 - */ -export function fetchAdminStats(): Promise { - return api.get(`${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 { - return api.get(`${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 { - return api.get(`${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): 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`) -} - -// ── 数据管线(质量检查 + 失败重试) ────────────────────────────── - -export interface IngestFailureItem { - id: number - source: string - entity_type: string - source_record_id?: string - error_type: string - error_detail?: string - retry_count: number - status: string - next_retry_at?: string | null - created_at?: string -} - -export interface DataQualityCheckItem { - id: number - check_name: string - entity_type: string - passed: boolean - severity: string - detail?: Record | null - checked_at?: string -} - -export interface DataQualityResponse { - failures: IngestFailureItem[] - checks: DataQualityCheckItem[] -} - -export async function fetchDataQuality(): Promise { - return api.get(`${API_BASE}/admin/data-quality`) -} - -export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> { - return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`) -} - -export async function fetchIngestFailures(): Promise { - return api.get(`${API_BASE}/admin/ingest-failures`) -} - -export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> { - return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`) -} +export * from '../api/dal' diff --git a/frontend/src/admin/nav.ts b/frontend/src/admin/nav.ts index 91af3c5..80b1160 100644 --- a/frontend/src/admin/nav.ts +++ b/frontend/src/admin/nav.ts @@ -9,13 +9,20 @@ * 禁止再新建平行导航清单(修改入口/新增页面只改这里)。 */ +import type { AdminIconName } from './AdminIcon' + export interface NavItem { to: string label: string /** 命令面板中的分组名(展示原样) */ group: string - /** 侧栏图标名(见 AdminLayout 的 Icon) */ - icon: string + /** + * 侧栏图标名。 + * + * 使用 AdminIcon 的联合类型而非 `string`:图标名拼错时 + * 此前不会报任何错,只会静默渲染出一个空位,难以定位。 + */ + icon: AdminIconName /** 仅命令面板/面包屑可达,不进侧栏(深链页) */ hideFromSidebar?: boolean } diff --git a/frontend/src/admin/pages/Backtest.tsx b/frontend/src/admin/pages/Backtest.tsx index 2397c7d..0a66bb4 100644 --- a/frontend/src/admin/pages/Backtest.tsx +++ b/frontend/src/admin/pages/Backtest.tsx @@ -11,10 +11,11 @@ */ import { useEffect, useState } from 'react' -import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal' -import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types' +import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../../api/dal' +import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../../api/types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import TeamSideTag from '../../components/TeamSideTag' +import { Button, Input, Select } from '../../components/ui' interface BacktestResultRow { match_id: number @@ -38,8 +39,6 @@ interface BacktestResponse { results: BacktestResultRow[] } -const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } - /** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */ function exportCsv(rows: BacktestResultRow[]) { const header = [ @@ -144,10 +143,10 @@ export default function BacktestPage() { 联赛 - setLeagueId(e.target.value)} - className="field w-full" + className="w-full" > 全部联赛 {leagues.map(l => ( @@ -155,49 +154,49 @@ export default function BacktestPage() { {l.name_zh || l.name} ))} - + - 起始日期 - 起始日期 + setDateFrom(e.target.value)} - className="field w-full" + className="w-full" /> - 结束日期 - 结束日期 + setDateTo(e.target.value)} - className="field w-full" + className="w-full" /> - 场数限制 - 场数限制 + setLimit(parseInt(e.target.value) || 20)} - className="field w-full" + className="w-full" /> - 模式 - 模式 + @@ -206,20 +205,20 @@ export default function BacktestPage() { 指定模型(可选,空=默认 gpt-4o) - setModel(e.target.value)} placeholder="如 deepseek-chat / 留空使用默认" - className="field w-full" + className="w-full" /> {error && setError(null)} />} - + {loading ? (<> 回测中,逐场预测耗时较长>) : '开始回测'} - + @@ -233,7 +232,7 @@ export default function BacktestPage() { description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`} action={ result?.results?.length - ? ( exportCsv(result.results)} className="btn btn-sm">导出 CSV) + ? ( exportCsv(result.results)}>导出 CSV) : undefined } /> @@ -293,9 +292,9 @@ export default function BacktestPage() { title="模型评估" description="已结算预测的准确率统计" action={ - + {evalLoading ? (<> 加载中>) : '刷新'} - + } /> diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx index be760ee..e4fc8d9 100644 --- a/frontend/src/admin/pages/Collection.tsx +++ b/frontend/src/admin/pages/Collection.tsx @@ -11,10 +11,11 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' -import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal' -import type { IngestJob, League } from '../types' -import type { CollectionRequest } from '../types' +import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal' +import type { IngestJob, League } from '../../api/types' +import type { CollectionRequest } from '../../api/types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' +import { Button, Input, Select } from '../../components/ui' // 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji const TASKS = [ @@ -74,6 +75,22 @@ export default function CollectionPage() { useEffect(() => () => stopPolling(), [stopPolling]) + // ── 已运行时长 ── + // 必须放在 state 里、由定时器推进,而不是在 render 里读 Date.now()。 + // 后者是「渲染期间的副作用」:同一份 props/state 会渲染出不同结果, + // React 的并发特性(以及未来的编译器优化)都依赖渲染幂等。 + // 采集任务在跑时每秒推进一次,既给出真实的时长,也不制造额外重渲染。 + const [elapsedSec, setElapsedSec] = useState(0) + + useEffect(() => { + if (!taskStartedAt) { setElapsedSec(0); return } + if (taskStatus !== 'running') return + const id = setInterval(() => { + setElapsedSec(Math.round((Date.now() - taskStartedAt) / 1000)) + }, 1000) + return () => clearInterval(id) + }, [taskStartedAt, taskStatus]) + // ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ── // 声明须在 startJobPolling 之前(其终态回调会刷新历史) const [recentJobs, setRecentJobs] = useState(null) @@ -174,7 +191,7 @@ export default function CollectionPage() { } } - const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0 + const elapsed = elapsedSec const summary = jobInfo ? jobSummary(jobInfo) : null return ( @@ -214,51 +231,53 @@ export default function CollectionPage() { {/* 联赛选择 */} - 联赛 - 联赛 + setLeagueCode(e.target.value)} - className="field w-full" + className="w-full" > 全部联赛 {leagues.map(l => ( {l.name_zh || l.name} ))} - + {/* events/all 任务专用: 比赛状态 + 日期 */} {isEventsTask && ( <> - 比赛状态 - 比赛状态 + setIngestStatus(e.target.value)} - className="field w-full" + className="w-full" > 全部(已完赛 + 未开赛) 仅已完赛 仅未开赛 - + - 起始日期 - 起始日期 + setDateFrom(e.target.value)} - className="field w-full" + className="w-full" /> - 结束日期 - 结束日期 + setDateTo(e.target.value)} - className="field w-full" + className="w-full" /> @@ -268,13 +287,13 @@ export default function CollectionPage() { {/* standings 任务专用: 赛季 */} {(task === 'standings') && ( - 赛季(留空取当前赛季) - 赛季(留空取当前赛季) + setSeason(e.target.value)} placeholder="如 2026-2027" - className="field w-full" + className="w-full" /> )} @@ -283,13 +302,13 @@ export default function CollectionPage() { {(task === 'stats') && ( 单次最大回填比赛数(1-500) - setLimit(parseInt(e.target.value) || 100)} - className="field w-full" + className="w-full" /> 仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。 @@ -308,9 +327,9 @@ export default function CollectionPage() { )} {/* 提交按钮 */} - + {loading ? (<> 采集中>) : '触发采集'} - + @@ -366,7 +385,7 @@ export default function CollectionPage() { 刷新} + action={刷新} /> {recentJobs === null ? ( diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 4d567c4..63d54ab 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -11,9 +11,9 @@ import { useEffect, useState, useCallback } from 'react' import { Link } from 'react-router-dom' -import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal' -import type { DataCompletenessResponse, IngestFailureItem } from '../dal' -import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types' +import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal' +import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal' +import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types' import { Card, CardBody, CardHeader, SkeletonBlock } from '../components' /** 工作流引导(仅首次使用——库里还没有比赛时显示) */ diff --git a/frontend/src/admin/pages/DataCompleteness.tsx b/frontend/src/admin/pages/DataCompleteness.tsx index c4155c0..91ee52b 100644 --- a/frontend/src/admin/pages/DataCompleteness.tsx +++ b/frontend/src/admin/pages/DataCompleteness.tsx @@ -8,12 +8,13 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' -import { fetchDataCompleteness } from '../dal' -import type { DataCompletenessResponse } from '../dal' +import { fetchDataCompleteness } from '../../api/dal' +import type { DataCompletenessResponse } from '../../api/dal' import { Card, CardBody, CardHeader, SectionHeader, Alert, - ProgressBar, Spinner, EmptyState, + ProgressBar, Spinner, } from '../components' +import { Button } from '../../components/ui' const FIELD_LABELS: Record = { xg: 'xG 预期进球', @@ -119,9 +120,9 @@ export default function DataCompletenessPage() { /> 5s 自动刷新 - + {loading ? <> 刷新中> : '刷新'} - + } /> @@ -173,15 +174,12 @@ export default function DataCompletenessPage() { message={issue} action={action ? ( - scrollToLeague(action.code)} - className="btn btn-sm whitespace-nowrap" - > + scrollToLeague(action.code)}> 定位 - - + + {action.label} - + ) : undefined} /> diff --git a/frontend/src/admin/pages/DataPipeline.tsx b/frontend/src/admin/pages/DataPipeline.tsx index 19d4d20..2e79c11 100644 --- a/frontend/src/admin/pages/DataPipeline.tsx +++ b/frontend/src/admin/pages/DataPipeline.tsx @@ -13,9 +13,10 @@ import { runDataQualityCheck, fetchIngestFailures, retryIngestFailure, -} from '../dal' -import type { IngestFailureItem, DataQualityCheckItem } from '../dal' +} from '../../api/dal' +import type { IngestFailureItem, DataQualityCheckItem } from '../../api/dal' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' +import { Button } from '../../components/ui' export default function DataPipelinePage() { const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null) @@ -80,9 +81,9 @@ export default function DataPipelinePage() { title="数据管线" description="采集失败重试、数据质量检查与监控" action={ - + {running ? <> 检查中> : '运行质量检查'} - + } /> @@ -134,9 +135,9 @@ export default function DataPipelinePage() { {(f.status === 'pending' || f.status === 'retrying') && ( - handleRetry(f.id)} className="btn btn-sm"> + handleRetry(f.id)}> 重试 - + )} diff --git a/frontend/src/admin/pages/EvalPage.tsx b/frontend/src/admin/pages/EvalPage.tsx index 1d4bc01..611b658 100644 --- a/frontend/src/admin/pages/EvalPage.tsx +++ b/frontend/src/admin/pages/EvalPage.tsx @@ -8,9 +8,10 @@ * - 空态与加载态 */ import { useCallback, useEffect, useState } from 'react' -import { fetchEvalSummary, fetchLeagues } from '../dal' -import type { EvalSummary } from '../types' -import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components' +import { fetchEvalSummary, fetchLeagues } from '../../api/dal' +import type { EvalSummary, EvalSummaryRow, EvalCalibrationBucket } from '../../api/types' +import { Card, CardBody, CardHeader, StatCard, DataTable, Alert, Spinner, EmptyText } from '../components' +import { Button, Input, Select } from '../../components/ui' interface Filters { provider: string @@ -73,67 +74,67 @@ export default function EvalPage() { 提供商 - 模型 - Prompt 版本 - 模式 - 全部 single multi - + 联赛 - 全部 {leagues.map(l => ( {l.name ?? l.code} ))} - + - + {loading ? '加载中…' : '应用筛选'} - - + + 重置 - + @@ -166,27 +167,27 @@ export default function EvalPage() { ) : ( - columns={[ { key: 'provider', label: '提供商' }, { key: 'model', label: '模型' }, - { key: 'prompt_version', label: '版本', render: (row: any) => ( + { key: 'prompt_version', label: '版本', render: (row: EvalSummaryRow) => ( {row.prompt_version ?? '—'} ) }, { key: 'total', label: '评估条数' }, - { key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => ( + { key: 'accuracy_1x2', label: '1X2 准确率', render: (row: EvalSummaryRow) => ( {row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'} ) }, - { key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => ( + { key: 'avg_score_rmse', label: '比分 RMSE', render: (row: EvalSummaryRow) => ( {row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'} ) }, - { key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => ( + { key: 'avg_subjective_confidence', label: '平均置信度', render: (row: EvalSummaryRow) => ( {row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'} ) }, - { key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => ( + { key: 'calibration', label: '置信度校准(桶命中率)', render: (row: EvalSummaryRow) => ( row.calibration ? ( - {Object.entries(row.calibration).map(([name, b]: [string, any]) => ( + {Object.entries(row.calibration).map(([name, b]: [string, EvalCalibrationBucket]) => ( {name}: @@ -200,7 +201,7 @@ export default function EvalPage() { ) }, ]} data={summary} - rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`} + rowKey={(row: EvalSummaryRow) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`} emptyText="暂无评估数据" /> diff --git a/frontend/src/admin/pages/Logs.tsx b/frontend/src/admin/pages/Logs.tsx index 5aeb361..a9b5f46 100644 --- a/frontend/src/admin/pages/Logs.tsx +++ b/frontend/src/admin/pages/Logs.tsx @@ -8,9 +8,10 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' -import { fetchLogs } from '../dal' -import type { LogEntry } from '../types' +import { fetchLogs } from '../../api/dal' +import type { LogEntry } from '../../api/types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' +import { Button, Input } from '../../components/ui' const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const @@ -106,9 +107,9 @@ export default function LogsPage() { /> 10s 自动刷新 - + {loading ? (<> 加载中>) : '刷新'} - + } /> @@ -117,20 +118,22 @@ export default function LogsPage() { {LEVELS.map(lv => ( - setLevel(lv)} - className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`} + variant={level === lv ? 'solid' : 'default'} + size="sm" > {lv || '全部'} - + ))} - setKeyword(e.target.value)} placeholder="搜索关键字(消息 / logger)…" - className="field w-full sm:w-64" + aria-label="搜索日志关键字" + className="w-full sm:w-64" /> diff --git a/frontend/src/admin/pages/Monitoring.tsx b/frontend/src/admin/pages/Monitoring.tsx index 82047c4..39b9efa 100644 --- a/frontend/src/admin/pages/Monitoring.tsx +++ b/frontend/src/admin/pages/Monitoring.tsx @@ -12,14 +12,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' -import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../dal' -import type { DataCompletenessResponse, IngestFailureItem } from '../dal' -import type { IngestJob } from '../types' -import { api } from '../api' +import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../../api/dal' +import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal' +import type { IngestJob, LLMUsageStats, HealthProbe } from '../../api/types' +import { api } from '../../api/api' import { Alert, SectionHeader, Spinner } from '../components' +import { Button } from '../../components/ui' type UpstreamProbe = { ok: boolean; status_code?: number; latency_ms: number; endpoint: string; error?: string } -type LLMStats = { total_predictions: number; avg_latency_ms: number; success_rate: number } +// 直接引用 DAL 的权威类型,不要再本地手写一份 —— +// 本地别名会把 `avg_latency_ms: number | null` 悄悄收窄回 `number`, +// 使「未接入」在页面上重新退化成假数字。 +type LLMStats = LLMUsageStats interface TodoItem { key: string @@ -37,7 +41,7 @@ function MetricCard({ label, children }: { label: string; children: React.ReactN } export default function MonitoringPage() { - const [health, setHealth] = useState | null>(null) + const [health, setHealth] = useState(null) const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null) const [upstream, setUpstream] = useState(null) const [recentJobs, setRecentJobs] = useState(null) @@ -92,14 +96,21 @@ export default function MonitoringPage() { ) // ── 「需要关注」聚合:任何一项异常即亮红 ── - const todos: TodoItem[] = [ - !alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' }, - ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' }, - upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' }, - recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs} 次`, to: '/admin/collection' }, - deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount} 条`, to: '/admin/data-pipeline' }, - missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' }, - ].filter((t): t is TodoItem => t !== false) + // + // 过滤条件必须是「真值」而非 `t !== false`。因为这些短路表达式在条件 + // 不成立时返回的**不只是 false**:`upstream && !upstream.ok && {...}` + // 在 upstream 为 null 时整条求值为 null,而 `null !== false` 为 true, + // 于是 null 会穿过过滤器,渲染时 `t.to` 直接抛错并让整页崩进 ErrorBoundary。 + const todos = ( + [ + !alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' }, + ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' }, + upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' }, + recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs} 次`, to: '/admin/collection' }, + deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount} 条`, to: '/admin/data-pipeline' }, + missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' }, + ] as Array + ).filter((t): t is TodoItem => Boolean(t)) const okJobs = (recentJobs ?? []).filter(j => j.status === 'success').length const runJobs = (recentJobs ?? []).filter(j => j.status === 'success' || j.status === 'failed').length @@ -113,9 +124,9 @@ export default function MonitoringPage() { action={ {lastCheck && `最近巡检 ${lastCheck}`} - + {loading ? (<> 检查中>) : '立即巡检'} - + } /> @@ -156,7 +167,7 @@ export default function MonitoringPage() { - {health ? (alive ? '正常' : String(health.status)) : '—'} + {health ? (alive ? '正常' : health.status) : '—'} @@ -172,11 +183,11 @@ export default function MonitoringPage() { - {health?.version ? String(health.version) : '—'} + {health?.version ?? '—'} {health?.uptime_seconds != null - ? `已运行 ${Math.floor(Number(health.uptime_seconds) / 3600)}h ${Math.floor((Number(health.uptime_seconds) % 3600) / 60)}m` + ? `已运行 ${Math.floor(health.uptime_seconds / 3600)}h ${Math.floor((health.uptime_seconds % 3600) / 60)}m` : '—'} @@ -252,9 +263,11 @@ export default function MonitoringPage() { {llmStats ? llmStats.total_predictions : '—'} - + - {llmStats && llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'} + {llmStats?.avg_latency_ms != null + ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` + : '—'} diff --git a/frontend/src/admin/pages/PredictionHistory.tsx b/frontend/src/admin/pages/PredictionHistory.tsx index 3b3fef8..93ab79f 100644 --- a/frontend/src/admin/pages/PredictionHistory.tsx +++ b/frontend/src/admin/pages/PredictionHistory.tsx @@ -8,10 +8,11 @@ */ import { useCallback, useEffect, useState } from 'react' -import { fetchPredictions, settlePrediction } from '../dal' -import type { Prediction } from '../types' +import { fetchPredictions, settlePrediction } from '../../api/dal' +import type { Prediction } from '../../api/types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import TeamSideTag from '../../components/TeamSideTag' +import { Button } from '../../components/ui' const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } @@ -45,7 +46,7 @@ export default function PredictionHistoryPage() { useEffect(() => { load() }, [load]) const handleSettle = async (p: Prediction) => { - const match = (p as any).match + const match = p.match if (!match || match.home_goals == null || match.away_goals == null) return setSettlingId(p.id) try { @@ -110,13 +111,14 @@ export default function PredictionHistoryPage() { { v: 'unsettled', label: '待结算' }, { v: 'settled', label: '已结算' }, ] as const).map(opt => ( - setFilter(opt.v)} - className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`} + variant={filter === opt.v ? 'solid' : 'default'} + size="sm" > {opt.label} - + ))} @@ -154,7 +156,7 @@ export default function PredictionHistoryPage() { {filtered.map(p => { - const match = (p as any).match + const match = p.match const matchDate = match?.match_date const homeName = match?.home_team_zh || match?.home_team || '?' const awayName = match?.away_team_zh || match?.away_team || '?' @@ -216,20 +218,13 @@ export default function PredictionHistoryPage() { {!p.settled && hasActual && ( - handleSettle(p)} - disabled={settlingId === p.id} - className="btn btn-sm btn-solid" - > + handleSettle(p)} disabled={settlingId === p.id}> {settlingId === p.id ? : '结算'} - + )} - setExpandedId(isExpanded ? null : p.id)} - className="btn btn-sm" - > + setExpandedId(isExpanded ? null : p.id)}> {isExpanded ? '收起' : '详情'} - + @@ -246,7 +241,7 @@ export default function PredictionHistoryPage() { {expandedId && (() => { const p = predictions.find(pr => pr.id === expandedId) if (!p) return null - const match = (p as any).match + const match = p.match const reports = p.agent_outputs ?? [] return ( diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index fa3cbcd..ee9b843 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -15,14 +15,15 @@ import { testLLMConnection, fetchLLMUsageStats, fetchLLMModels, fetchKeyRingStatus, resetKeyRingCooldown, fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow, -} from '../dal' -import type { ScheduleItem } from '../dal' -import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api' -import type { LLMUsageStats, DataSourceSetting } from '../types' -import type { KeyRingStatusResponse } from '../dal' +} from '../../api/dal' +import type { ScheduleItem } from '../../api/dal' +import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../../api/api' +import type { LLMUsageStats, DataSourceSetting } from '../../api/types' +import type { KeyRingStatusResponse } from '../../api/dal' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' import SettingRow from '../SettingRow' import AgentLLMCard from '../AgentLLMCard' +import { Button, Input } from '../../components/ui' const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE'] const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL'] @@ -277,7 +278,7 @@ export default function SettingsPage() { {settingsLoading ? <> 加载中> : '刷新'}} + action={{settingsLoading ? <> 加载中> : '刷新'}} /> {settingsLoading ? ( @@ -307,7 +308,7 @@ export default function SettingsPage() { 重置冷却} + action={重置冷却} /> {keyRing && keyRing.total > 0 ? ( @@ -345,7 +346,7 @@ export default function SettingsPage() { {settingsLoading ? <> 加载中> : '刷新'}} + action={{settingsLoading ? <> 加载中> : '刷新'}} /> {settingsLoading ? ( @@ -373,15 +374,15 @@ export default function SettingsPage() { )} - + {testing ? <> 测试中> : '测试 LLM 连接'} - + 测试会真实调用一次 LLM 预测,产生费用。 - {llmLoading ? <> 加载中> : '刷新'}} /> + {llmLoading ? <> 加载中> : '刷新'}} /> {llmLoading ? ( @@ -392,8 +393,16 @@ export default function SettingsPage() { 总预测数 - {llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'} - 平均延迟 + {/* 延迟统计后端未接入 → 明确显示「未接入」而非留白, + 免得被误读成「延迟为 0,性能极好」 */} + + {llmStats.avg_latency_ms != null + ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` + : '—'} + + + {llmStats.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'} + {llmStats.success_rate.toFixed(0)}% @@ -429,24 +438,24 @@ export default function SettingsPage() { - 当前密码 - setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" /> + 当前密码 + setCurrentPwd(e.target.value)} autoComplete="current-password" className="w-full" /> - 新密码(至少 8 位) - setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" /> + 新密码(至少 8 位) + setNewPwd(e.target.value)} autoComplete="new-password" className="w-full" /> - 确认新密码 - setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" /> + 确认新密码 + setConfirmPwd(e.target.value)} autoComplete="new-password" className="w-full" /> {pwdNotice && } 修改成功后会自动退出登录。 - + {pwdBusy ? <> 修改中> : '修改密码'} - + @@ -462,15 +471,9 @@ export default function SettingsPage() { title="采集调度" description="配置 cron 表达式定时触发采集任务" action={ - { - createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) - .then(() => fetchSchedules().then(setSchedules)) - }} - className="btn btn-sm" - > + { createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) .then(() => fetchSchedules().then(setSchedules)) }}> + 新建 - + } /> @@ -502,27 +505,20 @@ export default function SettingsPage() { )} - handleRunSchedule(s)} - disabled={scheduleBusyId === s.id} - className="btn btn-sm" - > + handleRunSchedule(s)} disabled={scheduleBusyId === s.id}> {scheduleBusyId === s.id ? : '立即执行'} - - + handleToggleSchedule(s)} disabled={scheduleBusyId === s.id} - className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`} + variant={s.enabled ? 'default' : 'solid'} + size="sm" > {scheduleBusyId === s.id ? : (s.enabled ? '禁用' : '启用')} - - handleDeleteSchedule(s)} - disabled={scheduleBusyId === s.id} - className="btn btn-sm btn-danger" - > + + handleDeleteSchedule(s)} disabled={scheduleBusyId === s.id}> {scheduleBusyId === s.id ? : '删除'} - + ))} diff --git a/frontend/src/admin/routes.tsx b/frontend/src/admin/routes.tsx index 0837e12..f7139af 100644 --- a/frontend/src/admin/routes.tsx +++ b/frontend/src/admin/routes.tsx @@ -3,36 +3,71 @@ * * 所有 Admin 页面的路由定义,使用嵌套路由。 * 挂载路径: /admin/* + * + * 全部页面采用 `lazy` 动态导入。 + * + * 此前这里是 10 个静态 import:App.tsx 引入 adminRoutes 就把 + * Dashboard/Collection/Logs/Eval/Backtest/Settings/Monitoring/ + * DataCompleteness/DataPipeline/PredictionHistory 连同它们的 + * 数据访问层一次性拉进首屏 —— 一个只想看赛程的匿名访客, + * 要为完整的后台界面付出流量。改为 lazy 后,后台代码只在实际 + * 访问 /admin/* 时才下载。 + * + * AdminLayout 保持静态引入:它是所有后台页面的公共外壳, + * 若也 lazy 则会与页面 chunk 形成串行请求(先下载 layout, + * 再下载页面),反而更慢。 */ +import { lazy, Suspense } from 'react' +import type { ReactNode } from 'react' import { Navigate } from 'react-router-dom' import AdminLayout from './AdminLayout' -import Dashboard from './pages/Dashboard' -import CollectionPage from './pages/Collection' -import DataCompletenessPage from './pages/DataCompleteness' -import PredictionsPage from './pages/PredictionHistory' -import BacktestPage from './pages/Backtest' -import MonitoringPage from './pages/Monitoring' -import SettingsPage from './pages/Settings' -import LogsPage from './pages/Logs' -import EvalPage from './pages/EvalPage' -import DataPipelinePage from './pages/DataPipeline' + +const Dashboard = lazy(() => import('./pages/Dashboard')) +const CollectionPage = lazy(() => import('./pages/Collection')) +const DataCompletenessPage = lazy(() => import('./pages/DataCompleteness')) +const DataPipelinePage = lazy(() => import('./pages/DataPipeline')) +const PredictionsPage = lazy(() => import('./pages/PredictionHistory')) +const BacktestPage = lazy(() => import('./pages/Backtest')) +const MonitoringPage = lazy(() => import('./pages/Monitoring')) +const SettingsPage = lazy(() => import('./pages/Settings')) +const LogsPage = lazy(() => import('./pages/Logs')) +const EvalPage = lazy(() => import('./pages/EvalPage')) + +/** + * 页面级懒加载的兜底:后台各页共用。 + * 保持极简,避免后台切换时出现大块占位跳动。 + */ +function AdminPageFallback() { + return ( + + + 加载中… + + + ) +} + +/** 用 Suspense 包住单个后台页面 */ +function page(node: ReactNode): ReactNode { + return }>{node} +} export const adminRoutes = [ { path: '/admin', element: , children: [ - { index: true, element: }, - { path: 'collection', element: }, - { path: 'data-completeness', element: }, - { path: 'data-pipeline', element: }, - { path: 'predictions', element: }, - { path: 'backtest', element: }, - { path: 'monitoring', element: }, - { path: 'settings', element: }, - { path: 'logs', element: }, - { path: 'eval', element: }, + { index: true, element: page() }, + { path: 'collection', element: page() }, + { path: 'data-completeness', element: page() }, + { path: 'data-pipeline', element: page() }, + { path: 'predictions', element: page() }, + { path: 'backtest', element: page() }, + { path: 'monitoring', element: page() }, + { path: 'settings', element: page() }, + { path: 'logs', element: page() }, + { path: 'eval', element: page() }, { path: '*', element: }, ], }, diff --git a/frontend/src/admin/types.ts b/frontend/src/admin/types.ts index eda3723..e6930f3 100644 --- a/frontend/src/admin/types.ts +++ b/frontend/src/admin/types.ts @@ -1,389 +1,5 @@ /** - * Admin 后台 - TypeScript 类型定义 - * - * 与 FastAPI 后端 Pydantic 模型对齐 + * 【兼容壳 · 已废弃】实现已迁至 `src/api/types.ts`。 + * 新代码请直接从 `../api/types` 导入。 */ - -// ── 系统健康 ──────────────────────────────────────────────────── - -export interface HealthStatus { - status: 'ok' | 'degraded' | 'error' - version?: string - uptime_seconds?: number - checks: Record -} - -// ── 仪表盘 ────────────────────────────────────────────────────── - -export interface DashboardStats { - leagues: League[] - // 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT, - // 不要再用列表 items.length 近似(上限 100 会严重失真) - total_predictions: number - health: string - db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] - last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[] - recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[] -} - -// ── 联赛 & 比赛 ───────────────────────────────────────────────── - -export interface League { - id?: number - code: string - name: string - name_zh?: string - country?: string -} - -export interface Match { - id: number - league_code?: string - season?: string | null - home_team: string - away_team: string - home_team_zh?: string | null - away_team_zh?: string | null - match_date: string - match_status: string - home_goals?: number | null - away_goals?: number | null - match_stage?: string | null -} - -// ── 预测 ──────────────────────────────────────────────────────── - -/** 列表接口返回的单路专家摘要字段 */ -export interface PredictionAgentOutput { - agent: string - status: string - analysis?: string | null - probable_score?: string | null - subjective_confidence?: number | null -} - -export interface Prediction { - id: number - match_id: number - provider: string - model: string - prompt_version?: string - mode?: string - pred_home_goals?: number | null - pred_away_goals?: number | null - pred_1x2?: string | null - subjective_confidence?: number | null - reasoning?: string | null - agent_outputs?: PredictionAgentOutput[] | null - agent_weights?: Record | null - status?: 'success' | 'failed' | 'degraded' - created_at: string - actual_home_goals?: number | null - actual_away_goals?: number | null - settled?: boolean -} - -export interface PredictRequest { - match_id: number - mode?: 'single' | 'multi' - provider?: string - model?: string -} - -// ── 数据采集 ──────────────────────────────────────────────────── - -export interface CollectionRequest { - status?: string - source: 'bzzoiro' - leagues?: string[] - task?: 'events' | 'standings' | 'stats' | 'all' - limit?: number - season?: string - date_from?: string - date_to?: string -} - -// ── 评估 & 回测 ───────────────────────────────────────────────── - -export interface EvalCalibrationBucket { - total: number - /** 该桶命中率,百分数;样本不足为 null */ - hit_rate: number | null -} - -export interface EvalSummaryRow { - provider: string - model: string - prompt_version: string | null - total: number - /** 1X2 准确率,百分数 0-100 */ - accuracy_1x2?: number - avg_score_rmse?: number | null - avg_subjective_confidence?: number | null - /** 置信度校准:按主观置信度分桶的命中率 */ - calibration?: Record -} - -export interface EvalSummary { - summary: Array - /** 全量已结算数 */ - total_settled: number - /** 应用筛选后的已结算数 */ - filtered_settled: number - /** 实际评估条数(status=success 且比分齐全) */ - evaluated: number - /** 跳过的 degraded 条数 */ - skipped_degraded: number - /** 跳过的比分不全条数 */ - skipped_incomplete?: number -} - -export interface BacktestRequest { - league_id?: number - date_from?: string - date_to?: string - mode?: 'single' | 'multi' - limit?: number - model?: string -} - -export interface BacktestSummary { - total: number - scored: number - success: number - degraded: number - accuracy_1x2?: number - avg_score_rmse?: number - avg_subjective_confidence?: number -} - -// ── 数据源配置 ────────────────────────────────────────────────── - -export interface DataSourceSetting { - key: string - label: string - description: string - sensitive: boolean - configured: boolean - masked: string - origin: 'db' | 'env' | 'none' -} - -export interface DataSourceStatus { - name: string - label: string - description: string - key_configured: boolean - last_ingestion: string | null - settings: DataSourceSetting[] -} - -export interface DataSourceTestResult { - ok: boolean - status: number | null - latency_ms: number - detail: string -} - -export interface DataSourceTestRequest { - source: 'bzzoiro' | 'understat' | 'injuries' -} - -export interface IngestionHistoryEntry { - id: string - source: string - started_at: string - finished_at: string | null - status: 'success' | 'running' | 'failed' - records_count: number | null - error_message: string | null -} - -// ── LLM 配置 ──────────────────────────────────────────────────── - -export interface LLMConfig { - provider: string - model: string - base_url: string - api_key_configured: boolean - api_key_masked: string -} - -export interface LLMUsageStats { - total_predictions: number - avg_latency_ms: number - success_rate: number - recent_predictions: Array<{ - id: number - match_id: number - model: string - created_at: string - latency_ms?: number - status: 'success' | 'failed' - }> -} - -// ── 系统配置 ──────────────────────────────────────────────────── - -export interface SystemConfigEntry { - key: string - value_masked: string - description: string - is_sensitive: boolean -} - -// ── 专家/终裁独立 LLM 配置 ──────────────────────────────────────── - -export interface LLMAgentFieldState { - configured: boolean - masked: string - origin: 'db' | 'env' | 'none' -} - -export interface LLMAgentConfig { - id: string - label: string - effective_model: string - fields: { - model: LLMAgentFieldState - base_url: LLMAgentFieldState - api_key: LLMAgentFieldState - } -} - -// ── 系统日志 ───────────────────────────────────────────────────── - -export interface LogEntry { - ts: number - level: string - logger: string - message: string -} - -// ── 数据源健康/最近采集状态 ───────────────────────────────────── - -export interface IngestLastFailure { - at: string - logger: string - detail: string - note: string -} - -export interface IngestSourceStatus { - name: string - label: string - key_configured: boolean - base_url?: string - reachable: boolean | null - status?: 'key_not_configured' | 'no_data' | 'has_data' - last_success_at: string | null - latest_match_date?: string | null - recent_count: number - note: string - last_failure: IngestLastFailure | null -} - -// ── 采集任务状态 ────────────────────────────────────────────── - -export interface IngestJob { - id: string - task: string - params: Record - status: 'pending' | 'running' | 'success' | 'failed' - result: Record | null - error: string | null - created_at: string | null - started_at: string | null - finished_at: string | null -} - -// ── 比赛详情 ───────────────────────────────────────────────────── - -export interface MatchRecentPrediction { - id: number - provider: string - model: string - mode: string - pred_home_goals: number | null - pred_away_goals: number | null - alt_pred_home_goals: number | null - alt_pred_away_goals: number | null - pred_1x2: string | null - subjective_confidence: number | null - reasoning: string | null - status: string - settled: boolean - correct_1x2?: boolean - created_at: string - actual_home_goals: number | null - actual_away_goals: number | null - agent_outputs?: Array> | null - agent_weights?: Record | null -} - -export interface MatchDetailOut { - id: number - league_code: string | null - season: string | null - home_team: string - away_team: string - home_team_zh: string | null - away_team_zh: string | null - match_date: string - match_status: string - home_goals: number | null - away_goals: number | null - match_stage: string | null - home_xg: number | null - away_xg: number | null - stats: MatchStatsDetail | null - recent_predictions: MatchRecentPrediction[] -} - -/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */ -export interface MatchStatsDetail { - home_xg: number | null - away_xg: number | null - home_shots: number | null - away_shots: number | null - home_shots_on_target: number | null - away_shots_on_target: number | null - home_corners: number | null - away_corners: number | null - home_possession: number | null - home_yellow_cards: number | null - away_yellow_cards: number | null - home_red_cards: number | null - away_red_cards: number | null - home_big_chances: number | null - away_big_chances: number | null - home_fouls: number | null - away_fouls: number | null -} - -export interface TeamRecentMatch { - match_date: string | null - home_team: string | null - away_team: string | null - home_goals: number | null - away_goals: number | null -} - -export interface MatchContextOut { - home_recent: TeamRecentMatch[] - away_recent: TeamRecentMatch[] - h2h: TeamRecentMatch[] -} - -// ── 管理区统计 ───────────────────────────────────────────────── - -export interface AdminStats { - predictions: { - total: number - last_24h: number - last_7d: number - } - matches?: { total: number; finished: number } - stats?: { total: number } - standings?: { total: number } -} +export * from '../api/types' diff --git a/frontend/src/admin/useCommandPalette.tsx b/frontend/src/admin/useCommandPalette.tsx index dedca02..89deb1c 100644 --- a/frontend/src/admin/useCommandPalette.tsx +++ b/frontend/src/admin/useCommandPalette.tsx @@ -7,8 +7,9 @@ * r — 刷新当前页面数据(通用) */ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { Modal } from '../components/ui' interface CommandItem { id: string @@ -94,17 +95,16 @@ export function CommandPalette({ } return ( - - e.stopPropagation()} - > + {/* 面板内点击不冒泡到遮罩(Modal 已做 target 判断,此处仅阻止穿透) */} + e.stopPropagation()}> {/* 搜索框 */} ⌘ @@ -112,6 +112,8 @@ export function CommandPalette({ value={query} onChange={e => setQuery(e.target.value)} placeholder="输入页面名或路径…" + /* 搜索框无可见标签,用 aria-label 提供可访问名称 */ + aria-label="搜索页面" autoFocus className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400" /> @@ -126,7 +128,7 @@ export function CommandPalette({ Object.entries(grouped).map(([group, groupItems]) => ( {group} - {groupItems.map((item, i) => ( + {groupItems.map(item => ( { item.action(); onClose() }} @@ -150,6 +152,6 @@ export function CommandPalette({ ESC 关闭 - + ) } diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts new file mode 100644 index 0000000..eeca970 --- /dev/null +++ b/frontend/src/api/api.ts @@ -0,0 +1,66 @@ +/** + * 应用统一 API 客户端(门面)—— 2026-09 自 admin/api.ts 迁入。 + * + * 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话, + * 同源请求自动携带 Cookie,无需手动管理密钥。 + * 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。 + * + * 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份), + * 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。 + */ + +import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http' + +/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */ +export { ApiError, UNAUTHORIZED_EVENT } + +const API_BASE = '/api/v1' + +/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */ +type ApiOpts = { + timeoutMs?: number + /** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */ + skipAuthHandling?: boolean +} + +export const api = { + get: (path: string) => http.get(path), + post: (path: string, body?: unknown, opts?: ApiOpts) => + http.post(path, body, opts), + put: (path: string, body?: unknown, opts?: ApiOpts) => + http.put(path, body, opts), + delete: (path: string) => http.delete(path), +} + +// ── 认证 ──────────────────────────────────────────────────────── + +/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */ +export function login(password: string): Promise<{ ok: boolean }> { + return api.post(`${API_BASE}/auth/login`, { password }) +} + +/** 退出登录,清除会话 Cookie */ +export function logout(): Promise<{ ok: boolean }> { + return api.post(`${API_BASE}/auth/logout`) +} + +/** 探测当前登录状态 */ +export function fetchAuthState(): Promise<{ + authenticated: boolean + enabled: boolean + password_origin?: 'db' | 'env' | 'none' +}> { + return api.get(`${API_BASE}/auth/me`) +} + +/** 修改管理员密码(成功后所有会话失效,需重新登录) */ +export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> { + // skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出 + return api.post( + `${API_BASE}/auth/change-password`, + { current_password: currentPassword, new_password: newPassword }, + { skipAuthHandling: true }, + ) +} + +export { API_BASE } diff --git a/frontend/src/api/dal.ts b/frontend/src/api/dal.ts new file mode 100644 index 0000000..9439c62 --- /dev/null +++ b/frontend/src/api/dal.ts @@ -0,0 +1,481 @@ +/** + * 应用数据访问层 —— 需要鉴权的管理端点集合。 + * + * 封装所有 API 端点调用,返回类型安全的数据, + * 所有端点对齐 FastAPI 后端实际实现。 + * + * 归属说明:2026-09 从 `admin/dal.ts` 迁入 `src/api/`。 + * 公开(免登录)端点在 `./public.ts`;admin/ 下仅留兼容壳。 + */ + +import { api, API_BASE } from './api' +import type { + DashboardStats, + CollectionRequest, + BacktestRequest, + BacktestSummary, + League, + Match, + Prediction, + EvalSummary, + DataSourceStatus, + DataSourceSetting, + DataSourceTestResult, + LLMAgentConfig, + LogEntry, + IngestSourceStatus, + IngestJob, + AdminStats, + LLMUsageStats, + IngestTriggerResult, + PredictionJobRef, + LLMPingResult, + SettleResult, + HealthProbe, +} from './types' + +// ── 仪表盘 ────────────────────────────────────────────────────── + +/** + * 从多个端点聚合仪表盘数据。 + * 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。 + * + * 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats, + * 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似, + * 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。 + */ +export async function fetchDashboard(): Promise { + // 并行获取各端点数据 + // predictions 返回数组 + const [leagues, predictions, health] = await Promise.allSettled([ + api.get(`${API_BASE}/leagues`), + api.get(`${API_BASE}/predictions?limit=100`), + api.get<{ status: string }>('/health'), + ]) + + return { + leagues: leagues.status === 'fulfilled' ? leagues.value : [], + // predictions 直接返回数组 + total_predictions: predictions.status === 'fulfilled' ? predictions.value?.length ?? 0 : 0, + health: health.status === 'fulfilled' ? health.value.status : 'unknown', + // 以下三项目前无对应后端端点。返回 null 而非空数组: + // 空数组会被读成「查过了,没有数据」,而事实是「还没接」。 + db_tables: null, + last_collection: null, + recent_errors: null, + } +} + +// ── 数据采集 ──────────────────────────────────────────────────── + +export async function triggerCollection(req: CollectionRequest): Promise { + // 后端 IngestBzzoiroRequest 的字段全部为可选,用 Partial 显式表达 + // 「未选中的筛选项不发」,而不是靠 `Record` 蒙混。 + const body: Partial & { task: string; limit: number } = { + 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 { + return api.post(`${API_BASE}/predict`, { + match_id: req.match_id, + mode: req.mode || 'multi', + }) +} + +export async function fetchPredictions(limit = 50): Promise { + const res = await api.get( + `${API_BASE}/predictions?limit=${limit}`, + ) + // 后端声明为 list[PredictionOut];保留 items 分支兼容网关包装层。 + if (Array.isArray(res)) return res + return res?.items ?? [] +} + +// ── 评估 & 回测 ───────────────────────────────────────────────── + +export async function fetchEvalSummary(params: { + limit?: number + provider?: string + model?: string + prompt_version?: string + mode?: string + league_code?: string +} = {}): Promise { + 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(`${API_BASE}/eval/summary?${sp}`) + } catch { + return null + } +} + +export async function triggerBacktest(req: BacktestRequest): Promise { + return api.post(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 }) +} + +// ── 辅助数据 ──────────────────────────────────────────────────── + +export async function fetchLeagues(): Promise { + try { + return await api.get(`${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<{ items: Match[]; has_next: boolean; next_cursor: string | null }>( + `${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 { + return api.post(`${API_BASE}/eval/settle`, { + prediction_id, + home_goals, + away_goals, + }) +} + +// ── 健康检查 ──────────────────────────────────────────────────── + +export async function fetchHealth(): Promise { + try { + return await api.get('/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 + } + standings: { rows: number; latest_retrieved?: string } + }> +} + +export async function fetchDataCompleteness(): Promise { + return api.get(`${API_BASE}/admin/data-completeness`) +} + +// ── 积分榜 & 比赛详情 ─────────────────────────────────────────── +// +// 这几者与它们的类型已迁到 `src/api/public.ts` —— 它们是**无需登录** +// 的公开端点,却是前台页面的数据来源。留在这里会让公开页反向依赖 +// admin 层,并把 41 个后台端点一起拖进用户首屏的 chunk。 +// +// 此处保留再导出,使既有 `from './dal'` 的调用路径继续可用; +// 新代码请直接从 `src/api/public` 导入。 + +export { fetchStandings, fetchMatchDetail, fetchMatchContext } from '../api/public' +export type { StandingRow, StandingsLeague } from '../api/public' + +// ── 数据源管理 ────────────────────────────────────────────────── + +/** + * 测试数据源连通性 — 后端真实请求上游一次,不触发入库 + */ +export function testDataSourceConnection(name: string): Promise { + return api.post(`${API_BASE}/admin/datasources/${name}/test`) +} + +/** + * 获取数据源状态与配置(脱敏) + */ +export function fetchDataSourceStatuses(): Promise { + return api.get(`${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 { + return api.get(`${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 { + return api.get(`${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 { + // 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', + }) + } +} + +/** + * 获取 LLM 使用统计 — 从预测列表聚合。 + * + * 注意:延迟统计是**后端尚未提供的维度**。此前这里返回硬编码的 + * `avg_latency_ms: 2400`,它会穿过类型检查、被渲染成「2.4s」, + * 并作为真实性能指标进入人的判断。现已改为 `null`,由 UI 显示 + * 「—」并标注未接入。宁可留空,不可编数。 + */ +export async function fetchLLMUsageStats(): Promise { + try { + const predictions = await fetchPredictions(50) + const total = predictions.length + const successCount = predictions.filter(p => p.pred_1x2).length + return { + total_predictions: total, + avg_latency_ms: null, // 后端暂无延迟统计端点 + success_rate: total > 0 ? (successCount / total) * 100 : 0, + recent_predictions: predictions.slice(0, 10).map(p => ({ + id: p.id, + match_id: p.match_id, + model: p.model, + created_at: p.created_at, + status: p.pred_1x2 ? 'success' as const : 'failed' as const, + })), + } + } catch { + return { + total_predictions: 0, + avg_latency_ms: null, + success_rate: 0, + recent_predictions: [], + } + } +} + +/** + * 数据源健康/最近采集状态(只读,不触发采集) + */ +export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> { + return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`) +} + +/** + * 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额 + */ +export function fetchUpstreamProbe(): Promise<{ + ok: boolean + status_code?: number + latency_ms: number + endpoint: string + error?: string +}> { + return api.get(`${API_BASE}/admin/monitoring/upstream`) +} + +/** + * 采集任务状态轮询(单任务) + */ +export function fetchIngestJob(jobId: string): Promise { + return api.get(`${API_BASE}/admin/ingest/jobs/${jobId}`) +} + +/** + * 采集任务历史列表(GET /admin/ingest/jobs,最新在前) + */ +export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise { + const q = new URLSearchParams() + if (params.limit != null) q.set('limit', String(params.limit)) + if (params.status) q.set('status', params.status) + const qs = q.toString() + return api.get(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`) +} + +/** + * 管理区统计(只读):近 24h/7d 预测次数 + */ +export function fetchAdminStats(): Promise { + return api.get(`${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 { + return api.get(`${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 { + return api.get(`${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): 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`) +} + +// ── 数据管线(质量检查 + 失败重试) ────────────────────────────── + +export interface IngestFailureItem { + id: number + source: string + entity_type: string + source_record_id?: string + error_type: string + error_detail?: string + retry_count: number + status: string + next_retry_at?: string | null + created_at?: string +} + +export interface DataQualityCheckItem { + id: number + check_name: string + entity_type: string + passed: boolean + severity: string + detail?: Record | null + checked_at?: string +} + +export interface DataQualityResponse { + failures: IngestFailureItem[] + checks: DataQualityCheckItem[] +} + +export async function fetchDataQuality(): Promise { + return api.get(`${API_BASE}/admin/data-quality`) +} + +export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> { + return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`) +} + +export async function fetchIngestFailures(): Promise { + return api.get(`${API_BASE}/admin/ingest-failures`) +} + +export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> { + return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`) +} diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts new file mode 100644 index 0000000..a7d2b40 --- /dev/null +++ b/frontend/src/api/public.ts @@ -0,0 +1,67 @@ +/** + * 前台公开端点 —— 预测主站与积分榜所需的最小数据访问。 + * + * 为什么要单独成文件(而不是继续挂在 `admin/dal.ts` 里): + * + * 1. **分层方向错了。** 面向公众的页面反向 import `../admin/dal`, + * 意味着想重构后台就会牵连前台,目录名也持续误导人以为 + * `admin/` 是可独立删除的包。公开端点的归属本就不该是 admin。 + * + * 2. **它真的会进用户的首屏。** `dal.ts` 是单个大模块,内部函数 + * 相互引用,打包器无法按调用点做 tree-shaking —— 实测 + * Matches 只用 2 个函数,但 `/admin/ingest/jobs`、`/admin/llm/models`、 + * `/admin/eval`、`/admin/backtest`、`/admin/logs` 这些字符串全部 + * 留在了公开页所在 chunk 里。抽出来后可省下约 55 kB 首屏 JS。 + * + * 本文件只放**无需登录即可访问**的端点。任何需要鉴权的端点继续留在 + * `./dal.ts`。新增公开端点时请加在这里,不要加回 admin。 + */ + +import { api, API_BASE } from './api' +import type { MatchDetailOut, MatchContextOut } from './types' + +// ── 积分榜 ────────────────────────────────────────────────────── + +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 fetchMatchDetail(id: number): Promise { + return api.get(`${API_BASE}/matches/${id}`) +} + +export function fetchMatchContext(id: number): Promise { + return api.get(`${API_BASE}/matches/${id}/context`) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..158ad0a --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,503 @@ +/** + * 应用级 API 类型定义(2026-09 自 admin/types.ts 迁入)。 + * + * 与 FastAPI 后端 Pydantic 模型对齐。 + */ + +// ── 系统健康 ──────────────────────────────────────────────────── + +export interface HealthStatus { + status: 'ok' | 'degraded' | 'error' + version?: string + uptime_seconds?: number + checks: Record +} + +// ── 仪表盘 ────────────────────────────────────────────────────── + +export interface DashboardStats { + leagues: League[] + // 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT, + // 不要再用列表 items.length 近似(上限 100 会严重失真) + total_predictions: number + health: string + /** + * 以下三个字段对应的后端端点在**当前版本中并不存在**。 + * + * 它们曾以 `[]` 返回,调用方无法区分「确实没有错误」与 + * 「这个功能还没接」——空数组是一个会被当成结论的事实断言。 + * 改为可空,让「未接入」在类型层面无法被忽略。 + * 后端补齐端点后,把类型收窄回数组即可(编译器会指出所有消费点)。 + */ + db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] | null + last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[] | null + recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[] | null +} + +// ── 联赛 & 比赛 ───────────────────────────────────────────────── + +export interface League { + id?: number + code: string + name: string + name_zh?: string + country?: string +} + +export interface Match { + id: number + league_code?: string + season?: string | null + home_team: string + away_team: string + home_team_zh?: string | null + away_team_zh?: string | null + match_date: string + match_status: string + home_goals?: number | null + away_goals?: number | null + match_stage?: string | null +} + +// ── 预测 ──────────────────────────────────────────────────────── + +/** 列表接口返回的单路专家摘要字段 */ +export interface PredictionAgentOutput { + agent: string + status: string + analysis?: string | null + probable_score?: string | null + subjective_confidence?: number | null +} + +export interface Prediction { + id: number + match_id: number + provider: string + model: string + prompt_version?: string + mode?: string + pred_home_goals?: number | null + pred_away_goals?: number | null + alt_pred_home_goals?: number | null + alt_pred_away_goals?: number | null + pred_1x2?: string | null + subjective_confidence?: number | null + reasoning?: string | null + agent_outputs?: PredictionAgentOutput[] | null + agent_weights?: Record | null + status?: 'success' | 'failed' | 'degraded' + created_at: string + actual_home_goals?: number | null + actual_away_goals?: number | null + settled?: boolean + /** + * 关联比赛摘要。后端 `PredictionOut.match` 是 `dict | None` + * (见 `src/api/schemas.py`),由 `_match_dict()` 拼出,字段与 `Match` + * 对齐但**每个字段都可能为 null**(比赛缺 league/team 时)。 + * + * 此前这个字段在类型里根本不存在,消费方只能写 `(p as any).match` + * ——类型系统失去了它唯一该起作用的地方。现在它被显式声明为可选。 + */ + match?: PredictionMatchRef | null +} + +/** + * 预测记录里内嵌的比赛摘要。 + * + * 独立于 `Match` 声明而不是直接复用,是因为二者契约不同: + * `Match` 的 `id`/`match_date` 是必填的(来自列表端点), + * 而这里是嵌套投影,后端 `_match_dict()` 在关联缺失时返回 null, + * 字段也可能缺失。混用会让必填约束说谎。 + */ +export interface PredictionMatchRef { + id?: number | null + league_code?: string | null + season?: string | null + home_team?: string | null + away_team?: string | null + home_team_zh?: string | null + away_team_zh?: string | null + match_date?: string | null + match_status?: string | null + home_goals?: number | null + away_goals?: number | null + match_stage?: string | null +} + +export interface PredictRequest { + match_id: number + mode?: 'single' | 'multi' + provider?: string + model?: string +} + +// ── 通用接口返回壳 ────────────────────────────────────────────── +// +// 这些类型此前在 dal.ts 里以 `Promise` 的形式存在,等于把后端契约 +// 丢掉了。声明在这里(与其它 API 类型同处)而不是 dal.ts 内联,是为了 +// 让消费方能直接 import type 而无需从实现文件取类型。 + +/** POST /api/v1/ingest/bzzoiro — 采集任务已受理,返回 job_id 供轮询 */ +export interface IngestTriggerResult { + ok?: boolean + job_id?: string + message?: string +} + +/** POST /api/v1/predict — 预测任务已受理(P1-async)。注意是 202 语义,非最终结果 */ +export interface PredictionJobRef { + job_id: string + status: 'running' | 'success' | 'failed' + poll_url?: string + /** job 完成后的信封:`_predict_jobs[id]` 里是 { status, result } 或 { status, error } */ + result?: Prediction + error?: string +} + +/** POST /api/v1/admin/llm/ping — 连通性探测 */ +export interface LLMPingResult { + ok?: boolean + provider?: string + model?: string + latency_ms?: number + message?: string + /** ping 端点尚未接入时后端可能只回一个透传对象 */ + [key: string]: unknown +} + +/** POST /api/v1/eval/settle — 结算结果 */ +export interface SettleResult { + ok?: boolean + settled?: number + message?: string +} + +/** + * `GET /health` 存活探针的实际返回。 + * + * 与文件开头那个 `HealthStatus` 不是同一个契约 —— 那个是 admin/stats + * 聚合口径(status 为 ok|degraded|error,带 checks 明细),这个才是 + * `src/api/app.py` 里 `/health` 直接吐出的结构。此前两者在 dal.ts 里 + * 共用同一个 `any`,差异被彻底抹平。 + * + * `version` / `uptime_seconds` 可空:后端在包元数据缺失时返回 null, + * 监控页据此降级隐藏版本卡片。 + */ +export interface HealthProbe { + status: 'healthy' | 'ok' | 'unknown' | string + service?: string + version?: string | null + uptime_seconds?: number | null +} + +// ── 数据采集 ──────────────────────────────────────────────────── + +export interface CollectionRequest { + status?: string + source: 'bzzoiro' + leagues?: string[] + task?: 'events' | 'standings' | 'stats' | 'all' + limit?: number + season?: string + date_from?: string + date_to?: string +} + +// ── 评估 & 回测 ───────────────────────────────────────────────── + +export interface EvalCalibrationBucket { + total: number + /** 该桶命中率,百分数;样本不足为 null */ + hit_rate: number | null +} + +export interface EvalSummaryRow { + provider: string + model: string + prompt_version: string | null + total: number + /** 1X2 准确率,百分数 0-100 */ + accuracy_1x2?: number + avg_score_rmse?: number | null + avg_subjective_confidence?: number | null + /** 置信度校准:按主观置信度分桶的命中率 */ + calibration?: Record +} + +export interface EvalSummary { + summary: Array + /** 全量已结算数 */ + total_settled: number + /** 应用筛选后的已结算数 */ + filtered_settled: number + /** 实际评估条数(status=success 且比分齐全) */ + evaluated: number + /** 跳过的 degraded 条数 */ + skipped_degraded: number + /** 跳过的比分不全条数 */ + skipped_incomplete?: number +} + +export interface BacktestRequest { + league_id?: number + date_from?: string + date_to?: string + mode?: 'single' | 'multi' + limit?: number + model?: string +} + +export interface BacktestSummary { + total: number + scored: number + success: number + degraded: number + accuracy_1x2?: number + avg_score_rmse?: number + avg_subjective_confidence?: number +} + +// ── 数据源配置 ────────────────────────────────────────────────── + +export interface DataSourceSetting { + key: string + label: string + description: string + sensitive: boolean + configured: boolean + masked: string + origin: 'db' | 'env' | 'none' +} + +export interface DataSourceStatus { + name: string + label: string + description: string + key_configured: boolean + last_ingestion: string | null + settings: DataSourceSetting[] +} + +export interface DataSourceTestResult { + ok: boolean + status: number | null + latency_ms: number + detail: string +} + +export interface DataSourceTestRequest { + source: 'bzzoiro' | 'understat' | 'injuries' +} + +export interface IngestionHistoryEntry { + id: string + source: string + started_at: string + finished_at: string | null + status: 'success' | 'running' | 'failed' + records_count: number | null + error_message: string | null +} + +// ── LLM 配置 ──────────────────────────────────────────────────── + +export interface LLMConfig { + provider: string + model: string + base_url: string + api_key_configured: boolean + api_key_masked: string +} + +export interface LLMUsageStats { + total_predictions: number + /** + * 平均延迟(毫秒)。**后端当前没有延迟统计端点**,故此处为 `null`。 + * + * 曾经这里是一个硬编码的 2400,并且会被渲染成看起来完全可信的 + * 「2.4s」。运营据此判断系统性能时,读到的是一个不存在的数字。 + * 改为 `null` 是刻意的:它让「未接入」这件事在类型层面强制可见, + * 调用方必须显式处理,而不是继承一个编造的默认值。 + */ + avg_latency_ms: number | null + success_rate: number + recent_predictions: Array<{ + id: number + match_id: number + model: string + created_at: string + latency_ms?: number + status: 'success' | 'failed' + }> +} + +// ── 系统配置 ──────────────────────────────────────────────────── + +export interface SystemConfigEntry { + key: string + value_masked: string + description: string + is_sensitive: boolean +} + +// ── 专家/终裁独立 LLM 配置 ──────────────────────────────────────── + +export interface LLMAgentFieldState { + configured: boolean + masked: string + origin: 'db' | 'env' | 'none' +} + +export interface LLMAgentConfig { + id: string + label: string + effective_model: string + fields: { + model: LLMAgentFieldState + base_url: LLMAgentFieldState + api_key: LLMAgentFieldState + } +} + +// ── 系统日志 ───────────────────────────────────────────────────── + +export interface LogEntry { + ts: number + level: string + logger: string + message: string +} + +// ── 数据源健康/最近采集状态 ───────────────────────────────────── + +export interface IngestLastFailure { + at: string + logger: string + detail: string + note: string +} + +export interface IngestSourceStatus { + name: string + label: string + key_configured: boolean + base_url?: string + reachable: boolean | null + status?: 'key_not_configured' | 'no_data' | 'has_data' + last_success_at: string | null + latest_match_date?: string | null + recent_count: number + note: string + last_failure: IngestLastFailure | null +} + +// ── 采集任务状态 ────────────────────────────────────────────── + +export interface IngestJob { + id: string + task: string + params: Record + status: 'pending' | 'running' | 'success' | 'failed' + result: Record | null + error: string | null + created_at: string | null + started_at: string | null + finished_at: string | null +} + +// ── 比赛详情 ───────────────────────────────────────────────────── + +export interface MatchRecentPrediction { + id: number + provider: string + model: string + mode: string + pred_home_goals: number | null + pred_away_goals: number | null + alt_pred_home_goals: number | null + alt_pred_away_goals: number | null + pred_1x2: string | null + subjective_confidence: number | null + reasoning: string | null + status: string + settled: boolean + correct_1x2?: boolean + created_at: string + actual_home_goals: number | null + actual_away_goals: number | null + /** + * 单路专家原始输出。后端 `agent_outputs` 是 `list[dict]`(未定 schema), + * 各专家的键随 agent 不同。这里用 `Record` 而非 `any`: + * 取值处必须显式收窄,编译器不再放行任意属性访问。 + */ + agent_outputs?: Array> | null + agent_weights?: Record | null +} + +export interface MatchDetailOut { + id: number + league_code: string | null + season: string | null + home_team: string + away_team: string + home_team_zh: string | null + away_team_zh: string | null + match_date: string + match_status: string + home_goals: number | null + away_goals: number | null + match_stage: string | null + home_xg: number | null + away_xg: number | null + stats: MatchStatsDetail | null + recent_predictions: MatchRecentPrediction[] +} + +/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */ +export interface MatchStatsDetail { + home_xg: number | null + away_xg: number | null + home_shots: number | null + away_shots: number | null + home_shots_on_target: number | null + away_shots_on_target: number | null + home_corners: number | null + away_corners: number | null + home_possession: number | null + home_yellow_cards: number | null + away_yellow_cards: number | null + home_red_cards: number | null + away_red_cards: number | null + home_big_chances: number | null + away_big_chances: number | null + home_fouls: number | null + away_fouls: number | null +} + +export interface TeamRecentMatch { + match_date: string | null + home_team: string | null + away_team: string | null + home_goals: number | null + away_goals: number | null +} + +export interface MatchContextOut { + home_recent: TeamRecentMatch[] + away_recent: TeamRecentMatch[] + h2h: TeamRecentMatch[] +} + +// ── 管理区统计 ───────────────────────────────────────────────── + +export interface AdminStats { + predictions: { + total: number + last_24h: number + last_7d: number + } + matches?: { total: number; finished: number } + stats?: { total: number } + standings?: { total: number } +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index c49eaa8..1ead22a 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,8 +1,17 @@ import { Component, ErrorInfo, ReactNode } from 'react' +import { Button } from './ui' interface Props { children: ReactNode fallback?: ReactNode + /** + * 该边界是否铺满整个视口。 + * + * 应用最外层的边界应铺满(`true`,默认);而当边界下沉到 + * 路由级、嵌在 SiteLayout 的 里时,铺满视口会撑开 + * 布局并让页头页脚错位 —— 那种场景传 `false`,改为局部卡片。 + */ + fullScreen?: boolean } interface State { @@ -29,20 +38,25 @@ export class ErrorBoundary extends Component { if (this.props.fallback) { return this.props.fallback } + const fullScreen = this.props.fullScreen ?? true return ( - + EXCEPTION 页面出现错误 {this.state.error?.message || '未知错误'} - this.setState({ hasError: false, error: null })} - className="btn btn-sm" - > + this.setState({ hasError: false, error: null })}> 重试 - + ) diff --git a/frontend/src/components/SiteLayout.tsx b/frontend/src/components/SiteLayout.tsx new file mode 100644 index 0000000..2692115 --- /dev/null +++ b/frontend/src/components/SiteLayout.tsx @@ -0,0 +1,47 @@ +/** + * SiteLayout —— 前台公开站点的共享外壳(报头 + 内容区 + 页脚)。 + * + * 背景:此前 `HomePage` 与 `StandingsLayout` 各自复制了一遍 + * `` + * 和 `
@@ -146,13 +145,13 @@ export default function SettingRow({ {setting.configured ? setting.masked : '—'}
gpt-4o
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。 @@ -308,9 +327,9 @@ export default function CollectionPage() { )} {/* 提交按钮 */} - + {loading ? (<> 采集中>) : '触发采集'} - + @@ -366,7 +385,7 @@ export default function CollectionPage() { 刷新} + action={刷新} /> {recentJobs === null ? ( diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 4d567c4..63d54ab 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -11,9 +11,9 @@ import { useEffect, useState, useCallback } from 'react' import { Link } from 'react-router-dom' -import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal' -import type { DataCompletenessResponse, IngestFailureItem } from '../dal' -import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types' +import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal' +import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal' +import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types' import { Card, CardBody, CardHeader, SkeletonBlock } from '../components' /** 工作流引导(仅首次使用——库里还没有比赛时显示) */ diff --git a/frontend/src/admin/pages/DataCompleteness.tsx b/frontend/src/admin/pages/DataCompleteness.tsx index c4155c0..91ee52b 100644 --- a/frontend/src/admin/pages/DataCompleteness.tsx +++ b/frontend/src/admin/pages/DataCompleteness.tsx @@ -8,12 +8,13 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' -import { fetchDataCompleteness } from '../dal' -import type { DataCompletenessResponse } from '../dal' +import { fetchDataCompleteness } from '../../api/dal' +import type { DataCompletenessResponse } from '../../api/dal' import { Card, CardBody, CardHeader, SectionHeader, Alert, - ProgressBar, Spinner, EmptyState, + ProgressBar, Spinner, } from '../components' +import { Button } from '../../components/ui' const FIELD_LABELS: Record = { xg: 'xG 预期进球', @@ -119,9 +120,9 @@ export default function DataCompletenessPage() { /> 5s 自动刷新 - + {loading ? <> 刷新中> : '刷新'} - +
测试会真实调用一次 LLM 预测,产生费用。
修改成功后会自动退出登录。
{group}
EXCEPTION
{this.state.error?.message || '未知错误'}