fix: 管理后台页面修复

- AdminLayout: 侧边栏布局和导航修复
- dal.ts: 数据访问层 API 调用修复
- Dashboard: 仪表盘统计卡片和数据加载修复
- Backtest: 回测配置和结果展示修复
- LLMConfig: LLM 配置页面修复
- Predictions: 预测管理页面修复
This commit is contained in:
shangfangjian
2026-09-20 08:45:27 +08:00
parent 7b1457f0db
commit 78375d6e39
6 changed files with 223 additions and 182 deletions
+71 -46
View File
@@ -11,19 +11,31 @@ import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal'
import Login from './Login'
const NAV_ITEMS = [
// ── 观测(只读) ──
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
{ to: '/admin/eval', label: '评估', icon: '◈' },
{ to: '/admin/monitoring', label: '监控', icon: '' },
{ to: '/admin/logs', label: '日志', icon: '' },
// ── 操作(写入,需登录) ──
{ to: '/admin/predictions', 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: '' },
const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
{
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/monitoring', label: '监控', icon: '◐' },
{ to: '/admin/logs', label: '日志', icon: '▤' },
],
},
{
title: '系统设置',
items: [
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', 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>
</div>
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
<ul className="space-y-0.5">
{NAV_ITEMS.map(item => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
isActive
? 'bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
{({ isActive }) => (
<>
{/* 导航:分组 + 小节标题 */}
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
{/* 仪表盘独立(始终第一项) */}
<NavLink
to="/admin"
end
className={({ isActive }) =>
`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
? 'bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
<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"
/>
<span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon}
</span>
{item.label}
</>
)}
</NavLink>
</li>
))}
</ul>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
{/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3">
<a
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>
@@ -212,13 +244,6 @@ export default function AdminLayout() {
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<a
href="/"
className="text-press transition-colors hover:text-press-dark sm:hidden"
aria-label="返回前台"
>
</a>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
+1 -1
View File
@@ -81,7 +81,7 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
injuries: {
path: `${API_BASE}/ingest/injuries`,
body: {
date: req.date_from || new Date().toISOString().slice(0, 10),
date: req.date_from || new Date().toLocaleDateString('sv-SE'),
},
},
}
+10 -12
View File
@@ -61,7 +61,7 @@ function exportCsv(rows: BacktestResultRow[]) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
a.click()
URL.revokeObjectURL(url)
}
@@ -73,7 +73,7 @@ function csvCell(v: string): string {
function fmtDate(s?: string | null): string {
if (!s) return '—'
return s.slice(0, 10)
return new Date(s).toLocaleDateString('sv-SE')
}
export default function BacktestPage() {
@@ -82,7 +82,7 @@ export default function BacktestPage() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [limit, setLimit] = useState(20)
const [mode, setMode] = useState<'single' | 'multi'>('single')
const [mode] = useState<'multi'>('multi')
const [model, setModel] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -193,14 +193,12 @@ export default function BacktestPage() {
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="single"> ()</option>
<option value="multi"> (,)</option>
</select>
<input
type="text"
value="多专家 (5 路 + 终裁)"
readOnly
className="field w-full bg-paper-100 text-ink-500"
/>
</div>
</div>
@@ -281,7 +279,7 @@ export default function BacktestPage() {
</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} (),
{summary.degraded} (),
</div>
)}
</div>
+131 -111
View File
@@ -1,141 +1,161 @@
/**
* Admin 后台 - 仪表盘(报刊风)
*
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
* 展示:
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
* - 近期预测活动(24h / 7d / 总计)
* - 快捷操作入口(带工作流引导)
*/
import { useEffect, useState } from 'react'
import { fetchDashboard, fetchHealth } from '../dal'
import type { DashboardStats } from '../types'
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
import { useEffect, useState, useCallback } from 'react'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
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() {
const [data, setData] = useState<DashboardStats | null>(null)
const [health, setHealth] = useState<any>(null)
const [stats, setStats] = useState<AdminStats | null>(null)
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
const load = useCallback(async () => {
setLoading(true)
Promise.all([fetchDashboard(), fetchHealth()])
.then(([stats, h]) => {
if (active) {
setData(stats)
setHealth(h)
}
})
.catch((err: unknown) => {
if (active) setError(err instanceof Error ? err.message : '加载失败')
})
.finally(() => {
if (active) setLoading(false)
})
return () => { active = false }
const [s, i, d] = await Promise.allSettled([
fetchAdminStats(),
fetchIngestStatus(),
fetchDashboard(),
])
if (s.status === 'fulfilled') setStats(s.value)
if (i.status === 'fulfilled') setIngest(i.value.sources)
if (d.status === 'fulfilled') setDash(d.value)
setLoading(false)
}, [])
if (error) {
return (
<div className="space-y-4">
<Alert kind="error" title="加载仪表盘失败" message={error} />
<button onClick={() => window.location.reload()} className="btn btn-sm">
</button>
</div>
)
}
useEffect(() => { load() }, [load])
const healthText =
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro']
const understat = sourceByName['understat']
const injuries = sourceByName['injuries']
return (
<div className="space-y-6">
{/* 统计:报纸数字版式 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="健康状态"
value={loading ? '—' : healthText}
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
/>
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
<div className="grid gap-4 sm:grid-cols-3">
{STEPS.map((s, i) => (
<a
key={s.to}
href={s.to}
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div className="flex items-center gap-2.5">
<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>
{/* 联赛列表 */}
{/* ── 数据源健康一览 ── */}
<Card>
<CardHeader title="已入库联赛" />
<CardBody>
<CardHeader
title="数据源健康"
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
/>
<CardBody className="px-0">
{loading ? (
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map(i => (
<SkeletonBlock key={i} className="h-6 w-24" />
))}
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : data && data.leagues.length > 0 ? (
) : (
<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>
<CardHeader title="已入库联赛" />
<CardBody>
<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"
>
{dash.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}
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
</span>
))}
</div>
) : (
<p className="text-xs text-ink-500">
,
</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>
</CardBody>
</Card>
)}
</div>
)
}
+1 -1
View File
@@ -181,7 +181,7 @@ export default function LLMConfigPage() {
{testing ? (<><Spinner /> </>) : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400">
, LLM
LLM ,
</p>
</CardBody>
</Card>
+9 -11
View File
@@ -37,7 +37,7 @@ export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([])
const [predictions, setPredictions] = useState<Prediction[]>([])
const [matchId, setMatchId] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [mode] = useState<'multi'>('multi')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
@@ -142,7 +142,7 @@ export default function PredictionsPage() {
{matches.map(m => (
<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)}
({m.match_date?.slice(5, 10)})
({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
</option>
))}
</select>
@@ -150,14 +150,12 @@ export default function PredictionsPage() {
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="multi"> (5 + ,)</option>
<option value="single"> ()</option>
</select>
<input
type="text"
value="多专家 (5 路 + 终裁)"
readOnly
className="field w-full bg-paper-100 text-ink-500"
/>
</div>
{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">
<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>