fix:批量修复了一些问题

This commit is contained in:
shangfangjian
2026-09-19 22:51:35 +08:00
parent 835d7217d0
commit 8e6ad5394e
44 changed files with 4921 additions and 395 deletions
+38 -11
View File
@@ -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>
)
}
+8 -5
View File
@@ -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>
+12 -9
View File
@@ -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 }
+56 -7
View File
@@ -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 (
+32
View File
@@ -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`)
}
+71 -10
View File
@@ -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>
+107 -46
View File
@@ -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>
+21 -3
View File
@@ -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
View File
@@ -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
}
}
+4 -1
View File
@@ -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
View File
@@ -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)
}