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
+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
}
}