fix: 管理后台页面修复
- AdminLayout: 侧边栏布局和导航修复 - dal.ts: 数据访问层 API 调用修复 - Dashboard: 仪表盘统计卡片和数据加载修复 - Backtest: 回测配置和结果展示修复 - LLMConfig: LLM 配置页面修复 - Predictions: 预测管理页面修复
This commit is contained in:
@@ -11,19 +11,31 @@ import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
|||||||
import { fetchHealth } from './dal'
|
import { fetchHealth } from './dal'
|
||||||
import Login from './Login'
|
import Login from './Login'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
|
||||||
// ── 观测(只读) ──
|
{
|
||||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
title: '数据流水线',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||||
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
|
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '评估与监控',
|
||||||
|
items: [
|
||||||
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
||||||
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
||||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
||||||
// ── 操作(写入,需登录) ──
|
],
|
||||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
},
|
||||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
{
|
||||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 报眉日期行,与前台同款式 */
|
/** 报眉日期行,与前台同款式 */
|
||||||
@@ -139,45 +151,65 @@ export default function AdminLayout() {
|
|||||||
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
|
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */}
|
{/* 导航:分组 + 小节标题 */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
|
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
||||||
<ul className="space-y-0.5">
|
{/* 仪表盘独立(始终第一项) */}
|
||||||
{NAV_ITEMS.map(item => (
|
|
||||||
<li key={item.to}>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.to}
|
to="/admin"
|
||||||
end={item.end}
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-press-wash/60 font-medium text-press'
|
||||||
|
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none opacity-50" aria-hidden="true">◇</span>
|
||||||
|
仪表盘
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{NAV_SECTIONS.map(section => (
|
||||||
|
<div key={section.title} className="mt-4">
|
||||||
|
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
|
||||||
|
{section.title}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{section.items.map(item => (
|
||||||
|
<li key={item.to}>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-press-wash/60 font-medium text-press'
|
? 'bg-press-wash/60 font-medium text-press'
|
||||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`}
|
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${
|
||||||
|
location.pathname.startsWith(item.to) ? 'bg-press' : 'bg-transparent'
|
||||||
|
}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-base leading-none opacity-50" aria-hidden="true">
|
<span className="text-base leading-none opacity-50" aria-hidden="true">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 底部 */}
|
{/* 底部 */}
|
||||||
<div className="border-t border-ink-200 px-4 py-3">
|
<div className="border-t border-ink-200 px-4 py-3">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
返回前台版面
|
返回前台版面
|
||||||
@@ -212,13 +244,6 @@ export default function AdminLayout() {
|
|||||||
/>
|
/>
|
||||||
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
||||||
</span>
|
</span>
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="text-press transition-colors hover:text-press-dark sm:hidden"
|
|
||||||
aria-label="返回前台"
|
|
||||||
>
|
|
||||||
前台
|
|
||||||
</a>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-ink-500 transition-colors hover:text-press"
|
className="text-ink-500 transition-colors hover:text-press"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
|||||||
injuries: {
|
injuries: {
|
||||||
path: `${API_BASE}/ingest/injuries`,
|
path: `${API_BASE}/ingest/injuries`,
|
||||||
body: {
|
body: {
|
||||||
date: req.date_from || new Date().toISOString().slice(0, 10),
|
date: req.date_from || new Date().toLocaleDateString('sv-SE'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function exportCsv(rows: BacktestResultRow[]) {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
|
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ function csvCell(v: string): string {
|
|||||||
|
|
||||||
function fmtDate(s?: string | null): string {
|
function fmtDate(s?: string | null): string {
|
||||||
if (!s) return '—'
|
if (!s) return '—'
|
||||||
return s.slice(0, 10)
|
return new Date(s).toLocaleDateString('sv-SE')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BacktestPage() {
|
export default function BacktestPage() {
|
||||||
@@ -82,7 +82,7 @@ export default function BacktestPage() {
|
|||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [limit, setLimit] = useState(20)
|
const [limit, setLimit] = useState(20)
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
const [mode] = useState<'multi'>('multi')
|
||||||
const [model, setModel] = useState('')
|
const [model, setModel] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -193,14 +193,12 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||||
<select
|
<input
|
||||||
value={mode}
|
type="text"
|
||||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
value="多专家 (5 路 + 终裁)"
|
||||||
className="field w-full"
|
readOnly
|
||||||
>
|
className="field w-full bg-paper-100 text-ink-500"
|
||||||
<option value="single">单次调用 (快)</option>
|
/>
|
||||||
<option value="multi">多专家 (慢,贵)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,7 +279,7 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
{summary.degraded > 0 && (
|
{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">
|
<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} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性或改用单次模式。
|
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,141 +1,161 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘(报刊风)
|
* Admin 后台 - 仪表盘(报刊风)
|
||||||
*
|
*
|
||||||
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
* 展示:
|
||||||
|
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||||
|
* - 近期预测活动(24h / 7d / 总计)
|
||||||
|
* - 快捷操作入口(带工作流引导)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { fetchDashboard, fetchHealth } from '../dal'
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
||||||
import type { DashboardStats } from '../types'
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||||
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
|
/** 工作流步骤卡片 */
|
||||||
|
const STEPS = [
|
||||||
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: '从 bzzoiro / understat 获取赛程与 xG', icon: '◈' },
|
||||||
|
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||||
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [data, setData] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [health, setHealth] = useState<any>(null)
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||||
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(async () => {
|
||||||
let active = true
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([fetchDashboard(), fetchHealth()])
|
const [s, i, d] = await Promise.allSettled([
|
||||||
.then(([stats, h]) => {
|
fetchAdminStats(),
|
||||||
if (active) {
|
fetchIngestStatus(),
|
||||||
setData(stats)
|
fetchDashboard(),
|
||||||
setHealth(h)
|
])
|
||||||
}
|
if (s.status === 'fulfilled') setStats(s.value)
|
||||||
})
|
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||||
.catch((err: unknown) => {
|
if (d.status === 'fulfilled') setDash(d.value)
|
||||||
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
setLoading(false)
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
})
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (error) {
|
useEffect(() => { load() }, [load])
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert kind="error" title="加载仪表盘失败" message={error} />
|
|
||||||
<button onClick={() => window.location.reload()} className="btn btn-sm">
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthText =
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||||
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
const bzzoiro = sourceByName['bzzoiro']
|
||||||
|
const understat = sourceByName['understat']
|
||||||
|
const injuries = sourceByName['injuries']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 统计:报纸数字版式 */}
|
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<StatCard
|
{STEPS.map((s, i) => (
|
||||||
label="健康状态"
|
<a
|
||||||
value={loading ? '—' : healthText}
|
key={s.to}
|
||||||
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
href={s.to}
|
||||||
/>
|
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||||
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
>
|
||||||
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
<div className="flex items-center gap-2.5">
|
||||||
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
||||||
|
{s.step}
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
||||||
|
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
||||||
|
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联赛列表 */}
|
{/* ── 数据源健康一览 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="数据源健康"
|
||||||
|
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 px-4 sm:px-5">
|
||||||
|
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{[
|
||||||
|
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||||
|
{ name: 'understat', label: 'Understat (xG)', st: understat },
|
||||||
|
{ name: 'injuries', label: 'Injuries (伤停)', st: injuries },
|
||||||
|
].map(({ name, label, st }) => {
|
||||||
|
const hasData = st && st.recent_count > 0
|
||||||
|
const keyOk = st?.key_configured !== false
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||||
|
<span className="text-xs font-medium text-ink-700">{label}</span>
|
||||||
|
<span className="flex items-center gap-3 text-2xs">
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
||||||
|
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
||||||
|
</>
|
||||||
|
) : keyOk ? (
|
||||||
|
<span className="text-ink-400">无数据</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-press">未配置 Key</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 近期预测活动 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
||||||
|
<CardBody>
|
||||||
|
{stats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-3xl 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-3xl 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-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 已入库联赛 ── */}
|
||||||
|
{dash && dash.leagues.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="已入库联赛" />
|
<CardHeader title="已入库联赛" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[1, 2, 3, 4].map(i => (
|
{dash.leagues.map(l => (
|
||||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : data && data.leagues.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{data.leagues.map(l => (
|
|
||||||
<span
|
|
||||||
key={l.code}
|
|
||||||
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
|
|
||||||
>
|
|
||||||
{l.name_zh || l.name}
|
{l.name_zh || l.name}
|
||||||
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</CardBody>
|
||||||
<p className="text-xs text-ink-500">
|
</Card>
|
||||||
暂无联赛数据,请先到「数据采集」导入比赛数据。
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="快捷操作" />
|
|
||||||
<CardBody>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<a
|
|
||||||
href="/admin/collection"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">触发采集</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">从数据源获取最新赛程与比分</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/predictions"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">新建预测</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">调 LLM 生成比赛预测</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/backtest"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">运行回测</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">在历史数据上检验准确率</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export default function LLMConfigPage() {
|
|||||||
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-2 text-center text-2xs text-ink-400">
|
<p className="mt-2 text-center text-2xs text-ink-400">
|
||||||
测试会真实调用一次单次模式预测,产生 LLM 费用。
|
测试会真实调用一次 LLM 预测,产生费用。
|
||||||
</p>
|
</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function PredictionsPage() {
|
|||||||
const [matches, setMatches] = useState<Match[]>([])
|
const [matches, setMatches] = useState<Match[]>([])
|
||||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||||
const [matchId, setMatchId] = useState('')
|
const [matchId, setMatchId] = useState('')
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
const [mode] = useState<'multi'>('multi')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||||
@@ -142,7 +142,7 @@ export default function PredictionsPage() {
|
|||||||
{matches.map(m => (
|
{matches.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
|
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
|
||||||
({m.match_date?.slice(5, 10)})
|
({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -150,14 +150,12 @@ export default function PredictionsPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||||
<select
|
<input
|
||||||
value={mode}
|
type="text"
|
||||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
value="多专家 (5 路 + 终裁)"
|
||||||
className="field w-full"
|
readOnly
|
||||||
>
|
className="field w-full bg-paper-100 text-ink-500"
|
||||||
<option value="multi">多专家 (5 路 + 终裁,慢而稳)</option>
|
/>
|
||||||
<option value="single">单次调用 (快)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
||||||
@@ -305,7 +303,7 @@ export default function PredictionsPage() {
|
|||||||
|
|
||||||
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
||||||
<p className="text-2xs text-ink-500">
|
<p className="text-2xs text-ink-500">
|
||||||
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
`多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效`
|
||||||
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user