fix:批量修复了一些问题
This commit is contained in:
+38
-11
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 主应用入口
|
||||
*
|
||||
* 整合前台(报纸风格)和后台(暗色管理)的路由。
|
||||
* - / → 先知(Profeto)主站
|
||||
* - /admin/* → 管理后台
|
||||
* 顶层三分区导航:
|
||||
* - 比赛/预测 → 公开,报纸风赛程 + 预测
|
||||
* - 评估 → 只读(后端需 admin 鉴权,未登录引导登录)
|
||||
* - 管理 → 采集/回测/配置等(需登录)
|
||||
*/
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { useState } from 'react'
|
||||
import Matches from './pages/Matches'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
@@ -21,10 +23,34 @@ function dateLine(): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** 顶层导航三分区 */
|
||||
type TopView = 'matches' | 'eval' | 'admin'
|
||||
|
||||
function TopNav({ onNavigate }: { onNavigate: (v: TopView) => void }) {
|
||||
const go = (v: TopView) => {
|
||||
onNavigate(v)
|
||||
const path = v === 'matches' ? '/' : v === 'eval' ? '/admin/eval' : '/admin'
|
||||
window.location.assign(path)
|
||||
}
|
||||
return (
|
||||
<nav className="flex items-center justify-center gap-1 border-b border-ink-200" aria-label="主导航">
|
||||
<button onClick={() => go('matches')} className="tab">
|
||||
<span aria-hidden="true">◇</span> 比赛 / 预测
|
||||
</button>
|
||||
<button onClick={() => go('eval')} className="tab">
|
||||
<span aria-hidden="true">◈</span> 评估
|
||||
</button>
|
||||
<button onClick={() => go('admin')} className="tab">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
||||
{/* ── 报头:粗线 + 居中刊名 + 顶层导航 ── */}
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
@@ -40,14 +66,8 @@ function HomePage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<a
|
||||
href="/admin"
|
||||
className="flex items-center gap-1.5 text-press hover:text-press-dark transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">⚙</span>
|
||||
管理后台
|
||||
</a>
|
||||
</div>
|
||||
<TopNav onNavigate={() => {}} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -64,12 +84,18 @@ function HomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */
|
||||
function AdminEntry() {
|
||||
return <Navigate to="/admin" replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminEntry />} />
|
||||
{adminRoutes.map(route => (
|
||||
<Route key={route.path} path={route.path} element={route.element}>
|
||||
{route.children.map(child => (
|
||||
@@ -88,3 +114,4 @@ export default function App() {
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,18 @@ import { fetchHealth } from './dal'
|
||||
import Login from './Login'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
// ── 观测(只读) ──
|
||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
||||
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
||||
// ── 操作(写入,需登录) ──
|
||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
||||
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||
{ to: '/admin/logs', label: '系统日志', icon: '▤' },
|
||||
{ to: '/admin/eval', label: '评估管理', icon: '◈' },
|
||||
]
|
||||
|
||||
/** 报眉日期行,与前台同款式 */
|
||||
@@ -220,6 +222,7 @@ export default function AdminLayout() {
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-ink-500 transition-colors hover:text-press"
|
||||
title="退出登录"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ApiError extends Error {
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { timeoutMs?: number } = {},
|
||||
options: RequestInit & { timeoutMs?: number; skipAuthHandling?: boolean } = {},
|
||||
): Promise<T> {
|
||||
// 修复: 正确拼接 API_BASE
|
||||
const url = path.startsWith('http')
|
||||
@@ -34,7 +34,7 @@ async function request<T>(
|
||||
? path // 已经是绝对路径(如 /health)
|
||||
: `${API_BASE}${path}`
|
||||
|
||||
const { timeoutMs = TIMEOUT_MS, ...fetchOptions } = options
|
||||
const { timeoutMs = TIMEOUT_MS, skipAuthHandling, ...fetchOptions } = options
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
@@ -59,7 +59,8 @@ async function request<T>(
|
||||
detail && typeof detail === 'object' && 'detail' in detail
|
||||
? String((detail as { detail: unknown }).detail)
|
||||
: `HTTP ${res.status}: ${res.statusText}`
|
||||
if (res.status === 401) {
|
||||
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
|
||||
if (res.status === 401 && !skipAuthHandling) {
|
||||
message += '\n登录已过期,请重新登录。'
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
||||
}
|
||||
@@ -88,9 +89,9 @@ async function request<T>(
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
|
||||
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
|
||||
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -118,10 +119,12 @@ export function fetchAuthState(): Promise<{
|
||||
|
||||
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post(`${API_BASE}/auth/change-password`, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
|
||||
return api.post(
|
||||
`${API_BASE}/auth/change-password`,
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
{ skipAuthHandling: true },
|
||||
)
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { ApiError } from './api'
|
||||
|
||||
// ── 卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
export function Card({
|
||||
@@ -271,7 +273,7 @@ export function Alert({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info'
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
@@ -279,17 +281,19 @@ export function Alert({
|
||||
const style =
|
||||
kind === 'error'
|
||||
? 'border-press bg-press-wash'
|
||||
: kind === 'ok'
|
||||
? 'border-ink-900 bg-paper-100'
|
||||
: 'border-ink-300 bg-paper-50'
|
||||
const titleCls = kind === 'error' ? 'text-press' : 'text-ink-900'
|
||||
: kind === 'warning'
|
||||
? 'border-press bg-press-wash/60'
|
||||
: kind === 'ok'
|
||||
? 'border-ink-900 bg-paper-100'
|
||||
: 'border-ink-300 bg-paper-50'
|
||||
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
|
||||
|
||||
return (
|
||||
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
|
||||
<div>
|
||||
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 ${kind === 'error' ? 'bg-press' : 'bg-ink-900'}`}
|
||||
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
@@ -315,7 +319,52 @@ export function Alert({
|
||||
)
|
||||
}
|
||||
|
||||
// ── 加载指示:同前台 Spinner ────────────────────────────────────
|
||||
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
||||
|
||||
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
||||
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
|
||||
if (err instanceof ApiError) {
|
||||
const status = err.status
|
||||
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
|
||||
? String((err.data as { detail: unknown }).detail)
|
||||
: ''
|
||||
const msg = apiDetail || err.message
|
||||
switch (status) {
|
||||
case 401:
|
||||
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
|
||||
case 403:
|
||||
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
|
||||
case 429:
|
||||
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
|
||||
case 502:
|
||||
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
|
||||
case 503:
|
||||
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
|
||||
case 0:
|
||||
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
|
||||
}
|
||||
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { title: '操作失败', detail: err.message, kind: 'error' }
|
||||
}
|
||||
return { title: '未知错误', detail: String(err), kind: 'error' }
|
||||
}
|
||||
|
||||
/** 统一错误横幅:用于页面级错误展示。 */
|
||||
export function ErrorBanner({
|
||||
err,
|
||||
onClose,
|
||||
}: {
|
||||
err: unknown
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { title, detail, kind } = describeError(err)
|
||||
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
|
||||
}
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
MatchDetailOut,
|
||||
MatchContextOut,
|
||||
AdminStats,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
@@ -307,3 +311,31 @@ export async function fetchSystemConfig(): Promise<any[]> {
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, EvalSummary, League } from '../types'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
|
||||
@@ -34,18 +34,43 @@ interface BacktestResultRow {
|
||||
}
|
||||
|
||||
interface BacktestResponse {
|
||||
summary: {
|
||||
total: number
|
||||
scored: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
summary: BacktestSummary
|
||||
results: BacktestResultRow[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
|
||||
function exportCsv(rows: BacktestResultRow[]) {
|
||||
const header = [
|
||||
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
|
||||
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
|
||||
]
|
||||
const lines = [header.join(",")]
|
||||
for (const r of rows) {
|
||||
lines.push([
|
||||
fmtDate(r.match_date), r.league_code ?? "",
|
||||
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
|
||||
r.actual_score, r.actual_1x2 ?? "",
|
||||
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
|
||||
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
|
||||
r.correct_1x2 ? "是" : "否",
|
||||
].join(","))
|
||||
}
|
||||
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** CSV 字段转义:含逗号/引号/换行时加引号 */
|
||||
function csvCell(v: string): string {
|
||||
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
|
||||
}
|
||||
|
||||
function fmtDate(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
return s.slice(0, 10)
|
||||
@@ -58,6 +83,7 @@ export default function BacktestPage() {
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
||||
const [model, setModel] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BacktestResponse | null>(null)
|
||||
@@ -90,6 +116,7 @@ export default function BacktestPage() {
|
||||
date_to: dateTo || undefined,
|
||||
limit,
|
||||
mode,
|
||||
model: model.trim() || undefined,
|
||||
}
|
||||
const res = await triggerBacktest(req as BacktestRequest)
|
||||
setResult(res as unknown as BacktestResponse)
|
||||
@@ -177,6 +204,19 @@ export default function BacktestPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">
|
||||
指定模型(可选,空=默认 <code className="font-mono">gpt-4o</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-chat / 留空使用默认"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
@@ -190,15 +230,31 @@ export default function BacktestPage() {
|
||||
<div className="space-y-6">
|
||||
{summary && (
|
||||
<Card>
|
||||
<CardHeader title="回测结果" />
|
||||
<CardHeader
|
||||
title="回测结果"
|
||||
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`}
|
||||
action={
|
||||
result?.results?.length
|
||||
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm">导出 CSV</button>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.scored}/{summary.total}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.success}/{summary.degraded}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">
|
||||
成功 / 降级<span className="text-ink-300"> (degraded)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
||||
@@ -223,6 +279,11 @@ export default function BacktestPage() {
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均置信度</div>
|
||||
</div>
|
||||
{summary.degraded > 0 && (
|
||||
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||||
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性或改用单次模式。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* Admin 后台 - 数据源管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示各数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
|
||||
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
|
||||
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataSourceStatuses,
|
||||
fetchIngestStatus,
|
||||
fetchAdminStats,
|
||||
updateSetting,
|
||||
clearSetting,
|
||||
testDataSourceConnection,
|
||||
} from '../dal'
|
||||
import type { DataSourceStatus, DataSourceTestResult } from '../types'
|
||||
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
||||
import SettingRow from '../SettingRow'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
@@ -31,6 +24,9 @@ export default function DataSourcesPage() {
|
||||
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
|
||||
const [ingestLoading, setIngestLoading] = useState(true)
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
|
||||
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
|
||||
@@ -52,16 +48,41 @@ export default function DataSourcesPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
|
||||
const loadIngest = useCallback(async () => {
|
||||
setIngestLoading(true)
|
||||
try {
|
||||
const { sources } = await fetchIngestStatus()
|
||||
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setIngestLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 管理区统计(只读)
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
setStats(await fetchAdminStats())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSources()
|
||||
}, [loadSources])
|
||||
loadIngest()
|
||||
loadStats()
|
||||
}, [loadSources, loadIngest, loadStats])
|
||||
|
||||
async function handleTest(sourceName: string) {
|
||||
setTestingSource(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
|
||||
try {
|
||||
const result = await testDataSourceConnection(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
|
||||
} finally {
|
||||
@@ -69,6 +90,47 @@ export default function DataSourcesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染数据源健康块(最近采集 + 异常提示)
|
||||
function renderHealth(sourceName: string) {
|
||||
const st = ingestStats[sourceName]
|
||||
if (!st) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近成功采集</span>
|
||||
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
|
||||
const issues: string[] = []
|
||||
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
|
||||
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
|
||||
if (st.last_failure) issues.push('近期有采集失败')
|
||||
return (
|
||||
<div className="space-y-1.5 border-t border-ink-200 pt-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近成功采集</span>
|
||||
<span className="text-ink-600">{ago}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">已入库(近期)</span>
|
||||
<span className="text-ink-600">{st.recent_count.toLocaleString()} 条</span>
|
||||
</div>
|
||||
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
|
||||
{issues.length > 0 && (
|
||||
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
|
||||
{issues.join(' / ')} — 请前往「数据采集」补采
|
||||
</p>
|
||||
)}
|
||||
{st.last_failure && (
|
||||
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
|
||||
最近失败: {st.last_failure.detail.slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
@@ -98,11 +160,12 @@ export default function DataSourcesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据源管理"
|
||||
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
|
||||
description="数据采集源的 API 配置、健康状态与连通性测试。"
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
@@ -132,7 +195,6 @@ export default function DataSourcesPage() {
|
||||
return (
|
||||
<Card key={source.name}>
|
||||
<CardBody className="space-y-4">
|
||||
{/* 头部 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
|
||||
<Badge status={source.key_configured ? 'success' : 'error'}>
|
||||
@@ -142,7 +204,6 @@ export default function DataSourcesPage() {
|
||||
|
||||
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
||||
|
||||
{/* 配置项 */}
|
||||
{source.settings.length > 0 ? (
|
||||
<div>
|
||||
{source.settings.map(setting => (
|
||||
@@ -151,10 +212,7 @@ export default function DataSourcesPage() {
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => {
|
||||
setEditingKey(setting.key)
|
||||
setRowNotice(null)
|
||||
}}
|
||||
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
@@ -165,36 +223,21 @@ export default function DataSourcesPage() {
|
||||
<p className="text-2xs text-ink-400">无需 API Key</p>
|
||||
)}
|
||||
|
||||
{/* 行级操作提示 */}
|
||||
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
||||
<Alert
|
||||
kind={rowNotice.ok ? 'ok' : 'error'}
|
||||
title={rowNotice.text}
|
||||
/>
|
||||
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
||||
)}
|
||||
|
||||
{/* 最近采集 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近采集</span>
|
||||
<span className="text-ink-600">{formatTime(source.last_ingestion)}</span>
|
||||
</div>
|
||||
{/* 数据源健康:最近采集 + 异常提示 */}
|
||||
{renderHealth(source.name)}
|
||||
|
||||
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
|
||||
{result && testingSource !== source.name && (
|
||||
<Alert
|
||||
kind={result.ok ? 'ok' : 'error'}
|
||||
title={
|
||||
result.ok
|
||||
? `连接成功(${result.latency_ms}ms)`
|
||||
: result.status
|
||||
? `HTTP ${result.status}`
|
||||
: '连接失败'
|
||||
}
|
||||
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
|
||||
message={result.ok ? undefined : result.detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 测试按钮 */}
|
||||
<button
|
||||
onClick={() => handleTest(source.name)}
|
||||
disabled={testingSource === source.name}
|
||||
@@ -209,23 +252,41 @@ export default function DataSourcesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置说明 */}
|
||||
{/* 近期活动统计(只读) */}
|
||||
{stats && stats.predictions && (
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
在此保存的配置存于数据库 <code className="font-mono">app_settings</code> 表并<b>立即生效</b>,
|
||||
优先于服务器 <code className="font-mono">.env</code> 中的同名变量;点「回落 .env」删除覆盖值。
|
||||
若两者都未配置,相应采集功能会报「Key 未设置」。
|
||||
保存的配置存于数据库 <code className="font-mono">app_settings</code> 并<b>立即生效</b>,优先于 <code className="font-mono">.env</code>;「回落 .env」删除覆盖值。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
敏感值只显示末 4 位(不足 8 位全遮),完整值不回传浏览器。
|
||||
「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
「测试连接」会真实请求上游接口一次:连通且密钥有效 → 成功并显示耗时;
|
||||
HTTP 401/403 → 密钥无效;其他状态码或超时 → 按详情提示排查。
|
||||
测试不写入任何数据。
|
||||
「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function EvalPage() {
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="准确率对比"
|
||||
description="按 provider × 模型聚合,仅统计有效预测"
|
||||
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
@@ -163,12 +163,15 @@ export default function EvalPage() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : summary.length === 0 ? (
|
||||
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'provider', label: '提供商' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'prompt_version', label: '版本', render: (row: any) => (
|
||||
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
|
||||
) },
|
||||
{ key: 'total', label: '评估条数' },
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
|
||||
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
|
||||
@@ -179,9 +182,24 @@ export default function EvalPage() {
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
|
||||
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
|
||||
row.calibration ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
|
||||
<span key={name} className="inline-flex items-center gap-1">
|
||||
<span className="text-ink-400">{name}:</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{b.hit_rate != null ? `${b.hit_rate}%` : '—'}
|
||||
</span>
|
||||
<span className="text-ink-300">({b.total})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : '—'
|
||||
) },
|
||||
]}
|
||||
data={summary}
|
||||
rowKey={(row: any) => `${row.provider}-${row.model}`}
|
||||
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
emptyText="暂无评估数据"
|
||||
/>
|
||||
)}
|
||||
|
||||
+112
-17
@@ -101,16 +101,27 @@ export interface CollectionRequest {
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
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<string, EvalCalibrationBucket>
|
||||
}
|
||||
|
||||
export interface EvalSummary {
|
||||
summary: Array<{
|
||||
provider: string
|
||||
model: string
|
||||
total: number
|
||||
/** 1X2 准确率,百分数 0-100 */
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number | null
|
||||
avg_subjective_confidence?: number | null
|
||||
}>
|
||||
summary: Array<EvalSummaryRow>
|
||||
/** 全量已结算数 */
|
||||
total_settled: number
|
||||
/** 应用筛选后的已结算数 */
|
||||
@@ -135,16 +146,11 @@ export interface BacktestRequest {
|
||||
export interface BacktestSummary {
|
||||
total: number
|
||||
scored: number
|
||||
success: number
|
||||
degraded: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
results?: Array<{
|
||||
match_id: number
|
||||
actual_home: number
|
||||
actual_away: number
|
||||
pred_home?: number
|
||||
pred_away?: number
|
||||
correct_1x2: boolean
|
||||
}>
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
@@ -249,3 +255,92 @@ export interface LogEntry {
|
||||
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 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<Record<string, any>> | null
|
||||
agent_weights?: Record<string, number> | 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
|
||||
recent_predictions: MatchRecentPrediction[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +65,14 @@
|
||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 py-1 text-xs;
|
||||
@apply px-2.5 py-1 text-xs min-h-[36px];
|
||||
}
|
||||
.btn-solid {
|
||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||
}
|
||||
|
||||
/* ── 表单控件:方正、无圆角 ── */
|
||||
.field {
|
||||
|
||||
+487
-124
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import TeamSideTag from '../components/TeamSideTag'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
||||
|
||||
interface Match {
|
||||
id: number
|
||||
@@ -31,10 +33,14 @@ interface Prediction {
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
status: string
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
prompt_tokens: number | null
|
||||
completion_tokens: number | null
|
||||
rate_limit_remaining: number | null
|
||||
}
|
||||
|
||||
interface AgentReport {
|
||||
@@ -182,6 +188,7 @@ function OutcomeLine({
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [date, setDate] = useState('') // 日期筛选(空=全部),"today"=今日
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -191,6 +198,10 @@ export default function Matches() {
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||
|
||||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
@@ -211,11 +222,12 @@ export default function Matches() {
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
|
||||
setLoadingMore(false)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
if (date === 'today') params.set('date', todayStr())
|
||||
else if (date) params.set('date', date)
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -229,7 +241,7 @@ export default function Matches() {
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
}, [league, status])
|
||||
}, [league, status, date])
|
||||
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
@@ -238,6 +250,8 @@ export default function Matches() {
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||
if (date === 'today') params.set('date', todayStr())
|
||||
else if (date) params.set('date', date)
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
@@ -255,7 +269,26 @@ export default function Matches() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */
|
||||
function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||
const map = new Map<string, Match[]>()
|
||||
for (const m of list) {
|
||||
const key = (m.match_date || '').slice(0, 10)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
// 防连点:若该场比赛已在预测中,直接忽略
|
||||
if (predictingId === m.id) return
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
setError(null)
|
||||
@@ -285,9 +318,7 @@ export default function Matches() {
|
||||
setError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试或改用单次模式'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
: readablePredictError(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
@@ -366,6 +397,27 @@ export default function Matches() {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span className="text-2xs text-ink-400">日期</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setDate(date === 'today' ? '' : 'today')}
|
||||
className={`btn btn-sm px-2 ${date === 'today' ? 'btn-solid' : ''}`}
|
||||
title="只看今日"
|
||||
>今日</button>
|
||||
<input
|
||||
type="date"
|
||||
value={date === 'today' ? todayStr() : date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="field px-1.5 py-1 text-xs"
|
||||
aria-label="按日期筛选"
|
||||
/>
|
||||
{date && (
|
||||
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="tabular-nums">共 {matches.length} 场</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
@@ -401,7 +453,7 @@ export default function Matches() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
@@ -412,79 +464,129 @@ export default function Matches() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const active = predictionFor?.id === m.id
|
||||
const finished = m.match_status === 'finished'
|
||||
{!loading && groupByDate(matches).map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
{/* 日期分组头 */}
|
||||
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500">
|
||||
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} 场</span>
|
||||
</div>
|
||||
{group.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const finished = m.match_status === 'finished'
|
||||
const expanded = expandedId === m.id
|
||||
const detail = detailMap[m.id]
|
||||
const ctx = contextMap[m.id]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
active ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
// 展开时懒加载详情(只读,不触发 LLM)
|
||||
async function toggleExpand() {
|
||||
if (expanded) { setExpandedId(null); return }
|
||||
setExpandedId(m.id)
|
||||
if (!detailMap[m.id] || !contextMap[m.id]) {
|
||||
setDetailLoading(m.id)
|
||||
try {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(m.id).catch(() => null),
|
||||
fetchMatchContext(m.id).catch(() => null),
|
||||
])
|
||||
if (d) setDetailMap(prev => ({ ...prev, [m.id]: d }))
|
||||
if (c) setContextMap(prev => ({ ...prev, [m.id]: c }))
|
||||
} finally {
|
||||
setDetailLoading(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{/* 行:可点击展开 */}
|
||||
<div
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${
|
||||
expanded ? 'bg-paper-100/60' : ''
|
||||
}`}
|
||||
onClick={toggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* 小屏:日期+状态行;桌面:日期单独一列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 比分 / VS */}
|
||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</span>
|
||||
<span className="flex w-16 flex-shrink-0 flex-col items-center">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态列(桌面) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
|
||||
{/* 预测按钮 */}
|
||||
<div className="flex justify-end">
|
||||
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn min-h-[44px] px-4"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 桌面端按钮(小屏隐藏) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* 关闭可点击行(clickable row) */}
|
||||
|
||||
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
|
||||
{expanded && (
|
||||
<MatchDetailPanel
|
||||
match={m} detail={detail} ctx={ctx}
|
||||
loading={detailLoading === m.id}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && nextCursor && (
|
||||
<div className="flex justify-center pt-4">
|
||||
@@ -499,6 +601,55 @@ export default function Matches() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||
function formatDateHeader(dateKey: string): string {
|
||||
if (!dateKey) return '未开赛'
|
||||
const d = new Date(dateKey + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKey
|
||||
const today = new Date()
|
||||
const todayKey = today.toISOString().slice(0, 10)
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = tmr.toISOString().slice(0, 10)
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/** 预测成本展示:耗时 + token + 限流余量 */
|
||||
function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
|
||||
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
|
||||
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
|
||||
: null
|
||||
|
||||
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||
{latency && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||
</span>
|
||||
)}
|
||||
{tokens && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||
</span>
|
||||
)}
|
||||
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||
<span className="text-press" title="每分钟最多 10 次预测">
|
||||
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
||||
@@ -573,8 +724,15 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
||||
{mode === 'multi'
|
||||
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消'
|
||||
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
||||
</p>
|
||||
{mode === 'multi' && (
|
||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -670,12 +828,14 @@ function PredictionPanel({
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const [expertsOpen, setExpertsOpen] = useState(false)
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{/* 版头(嵌入模式由弹窗报头承担) */}
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
@@ -694,71 +854,113 @@ function PredictionPanel({
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals !== null && prediction.alt_pred_away_goals !== null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
|
||||
{/* ── 元信息一行 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}
|
||||
</p>
|
||||
|
||||
{/* ── 专家意见 ── */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<section>
|
||||
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
|
||||
<span>五路专家意见</span>
|
||||
{prediction.agent_weights && (
|
||||
<span className="font-sans text-2xs font-normal text-ink-500">
|
||||
终裁权重:{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
{/* ── 主结论(仅 success 展示) ── */}
|
||||
{!degraded && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||
{!degraded && (
|
||||
<PredictionCost prediction={prediction} />
|
||||
)}
|
||||
|
||||
{/* ── 元信息 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
{/* ── 终裁/降级说明意见 ── */}
|
||||
{prediction.reasoning && degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<h4 className="section-head mb-2">降级原因</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
||||
{prediction.reasoning}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||
{mode === 'multi' && reports.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setExpertsOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||
>
|
||||
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
|
||||
{/* 权重条形图(仅 success 且有权重时显示) */}
|
||||
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||
{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||
<div className="h-1.5 bg-paper-100">
|
||||
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expertsOpen && (
|
||||
<div className="mt-2">
|
||||
{reports.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见(success) ── */}
|
||||
{prediction.reasoning && !degraded && mode === 'multi' && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
@@ -862,3 +1064,164 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||
function MatchDetailPanel({
|
||||
match, detail, ctx, loading, mode,
|
||||
}: {
|
||||
match: Match
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
loading: boolean
|
||||
mode: 'single' | 'multi'
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const finished = match.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> 加载详情中…</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail && !ctx && (
|
||||
<p className="py-4 text-center text-xs text-ink-400">暂无详情数据</p>
|
||||
)}
|
||||
|
||||
{!loading && (detail || ctx) && (
|
||||
<div className="space-y-5">
|
||||
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs text-ink-500">
|
||||
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
|
||||
</p>
|
||||
{match.home_xg != null && match.away_xg != null && (
|
||||
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}</p>
|
||||
)}
|
||||
</div>
|
||||
{!finished && (
|
||||
<span className="text-2xs text-ink-500">
|
||||
点击行首「预测」按钮发起{mode === 'multi' ? '多专家' : '单次'}分析
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 双方近况 + H2H */}
|
||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
|
||||
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
|
||||
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
|
||||
</div>
|
||||
) : (
|
||||
!loading && <p className="text-2xs text-ink-400">暂无近期对战数据</p>
|
||||
)}
|
||||
|
||||
{/* 历史预测列表 */}
|
||||
<div>
|
||||
<h4 className="section-head mb-2">历史预测({detail?.recent_predictions?.length ?? 0})</h4>
|
||||
{detail?.recent_predictions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.recent_predictions.map(p => (
|
||||
<PredictionHistoryRow key={p.id} p={p} mode={mode} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 近况/H2H 单区块 */
|
||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||
return (
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
|
||||
{rows && rows.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const date = r.match_date ? r.match_date.slice(5, 10) : '—'
|
||||
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
||||
const label = side === 'h2h'
|
||||
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
||||
: `${score}`
|
||||
return (
|
||||
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
|
||||
<span className="text-ink-400">{date}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-300">暂无</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 历史预测单行(含专家报告入口) */
|
||||
function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) {
|
||||
const badge = p.status === 'degraded'
|
||||
? { label: 'degraded', cls: 'text-press' }
|
||||
: p.settled
|
||||
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
|
||||
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
|
||||
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
|
||||
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
|
||||
: '—'
|
||||
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
||||
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
||||
const hasAgents = mode === 'multi' && p.agent_outputs && p.agent_outputs.length > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="tabular-nums text-ink-600">
|
||||
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
|
||||
{alt && <span className="ml-1 text-ink-400">备选 {alt}</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.subjective_confidence != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">信心 {Math.round(p.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||||
<span className="truncate">{p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'}</span>
|
||||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||||
</div>
|
||||
{p.reasoning && (
|
||||
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function readablePredictError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const m = e.message
|
||||
if (/429/.test(m)) {
|
||||
// 429 来自后端限流(每分钟 10 次),非上游 LLM
|
||||
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
|
||||
}
|
||||
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
|
||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试或改用单次模式'
|
||||
return m
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user