feat(admin): Settings 分区 tab 化 + Dashboard 改待办驱动

- Settings(496 行五块内容单页)拆为 4 个 tab:数据源/LLM/定时任务/登录认证;
  tab 状态写入 URL(?tab=llm)可深链;数据加载与 API 调用不变
- Dashboard 改待办驱动:死信待处理/联赛数据缺口/可结算预测,
  有待办才显示,每项直达处理页;全部清零时显示状态行
- 三步工作流卡仅在库里无比赛时显示(首次引导,老用户不再占版面)
- 修文案漂移:「数据源健康」卡不再引用已不存在的「数据源」页;
  「运行预测」链接语义修正(预测在前台比赛详情页发起)
- 数据源单源硬编码列表简化为一行;未配 Key 直达设置分区
- <a href> 全改 <Link>,不再整页刷新
This commit is contained in:
WorkBuddy
2026-09-22 12:44:12 +08:00
parent 9cedb874f7
commit 0ab838258c
2 changed files with 180 additions and 77 deletions
+134 -72
View File
@@ -1,137 +1,199 @@
/** /**
* Admin 后台 - 仪表盘(报刊风) * Admin 后台 - 仪表盘(报刊风·待办驱动)
* *
* 展示: * 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量) * 而不是扫描一堆常驻数字。
* - 近期预测活动(24h / 7d / 总计) * - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
* - 快捷操作入口(带工作流引导) * 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
* - 数据概览合并为一卡:预测活动 + 各表数据量
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal' import { Link } from 'react-router-dom'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal'
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types' import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components' import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
/** 工作流步骤卡片 */ /** 工作流引导(仅首次使用——库里还没有比赛时显示) */
const STEPS = [ const STEPS = [
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' }, { to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' }, { to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' }, { to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
] ]
interface TodoItem {
key: string
count: number
label: string
to: string
/** 无上限确认时数字近似,展示为 N+ */
approx?: boolean
}
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [ingest, setIngest] = useState<IngestSourceStatus[]>([]) const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null) const [dash, setDash] = useState<DashboardStats | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
const [s, i, d] = await Promise.allSettled([ // 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
const [s, i, d, f, c, p] = await Promise.allSettled([
fetchAdminStats(), fetchAdminStats(),
fetchIngestStatus(), fetchIngestStatus(),
fetchDashboard(), fetchDashboard(),
fetchIngestFailures(),
fetchDataCompleteness(),
fetchPredictions(100),
]) ])
if (s.status === 'fulfilled') setStats(s.value) if (s.status === 'fulfilled') setStats(s.value)
if (i.status === 'fulfilled') setIngest(i.value.sources) if (i.status === 'fulfilled') setIngest(i.value.sources)
if (d.status === 'fulfilled') setDash(d.value) if (d.status === 'fulfilled') setDash(d.value)
if (f.status === 'fulfilled') setFailures(f.value)
if (c.status === 'fulfilled') setCompleteness(c.value)
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
setLoading(false) setLoading(false)
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// ── 待办计算 ──
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
const missingStatsLeagues = completeness?.leagues.filter(
l => l.matches.finished > 0 && l.stats.rows === 0,
).length ?? 0
const missingStandingsLeagues = completeness?.leagues.filter(
l => l.matches.total > 0 && l.standings.rows === 0,
).length ?? 0
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
const settleable = recentPreds.filter(
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
).length
const todos: TodoItem[] = [
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
].filter((t): t is TodoItem => t !== false)
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s])) const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro'] const bzzoiro = sourceByName['bzzoiro']
const hasMatches = (stats?.matches?.total ?? 0) > 0
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 工作流引导(采集 → 预测 → 评估) ── */} {/* ── 待办行:只有真有待办才出现 ── */}
<div className="grid gap-4 sm:grid-cols-3"> {loading ? (
{STEPS.map((s, i) => ( <SkeletonBlock className="h-14 w-full" />
<a ) : todos.length > 0 ? (
key={s.to} <div className="grid gap-3 sm:grid-cols-3">
href={s.to} {todos.map(t => (
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100" <Link
> key={t.key}
<div className="flex items-center gap-2.5"> to={t.to}
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900"> className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
{s.step} >
<span className="font-serif text-2xl font-bold tabular-nums text-press">
{t.count}{t.approx ? '+' : ''}
</span> </span>
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span> <span className="text-xs text-ink-700">{t.label}</span>
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span> <span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true"></span>
</div> </Link>
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p> ))}
{i < STEPS.length - 1 && <span className="sr-only"></span>} </div>
</a> ) : (
))} <p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
</div> <span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
流水线无待办:没有失败记录
</p>
)}
{/* ── 数据源健康一览 ── */} {/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
{!loading && !hasMatches && (
<div className="grid gap-4 sm:grid-cols-3">
{STEPS.map(s => (
<Link
key={s.to}
to={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>
</Link>
))}
</div>
)}
{/* ── 数据源:单源一行即足,不装成列表 ── */}
<Card> <Card>
<CardHeader <CardHeader
title="数据源健康" title="数据源"
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)" description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
/> />
<CardBody className="px-0"> <CardBody className="px-0">
{loading ? ( {loading ? (
<div className="space-y-2 px-4 sm:px-5"> <div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : ( ) : (
<div> (() => {
{[ const st = bzzoiro
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro }, const hasData = st && st.recent_count > 0
].map(({ name, label, st }) => { const keyOk = st?.key_configured !== false
const hasData = st && st.recent_count > 0 return (
const keyOk = st?.key_configured !== false <div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
return ( <span className="text-xs font-medium text-ink-700">Bzzoiro</span>
<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="flex items-center gap-3 text-2xs">
<span className="text-xs font-medium text-ink-700">{label}</span> {hasData ? (
<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>
<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>
) : keyOk ? ( ) : (
<span className="text-ink-400"></span> <Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2"> Key,</Link>
) : ( )}
<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>
<span </div>
aria-hidden="true" )
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} })()
/>
</span>
</div>
)
})}
</div>
)} )}
</CardBody> </CardBody>
</Card> </Card>
{/* ── 近期预测活动 ── */} {/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
<Card> <Card>
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" /> <CardHeader title="数据概览" description="预测调用量与各表数据量" />
<CardBody> <CardBody>
{stats ? ( {stats ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</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 className="mt-1 text-2xs text-ink-400"> · 24 </div>
</div> </div>
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</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 className="mt-1 text-2xs text-ink-400"> · 7 </div>
</div> </div>
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</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 className="mt-1 text-2xs text-ink-400"> · </div>
</div> </div>
</div> </div>
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center"> <div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
<div> <div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div> <div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
+46 -5
View File
@@ -9,6 +9,7 @@
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { import {
fetchSettings, updateSetting, clearSetting, fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels, testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
@@ -26,7 +27,24 @@ import AgentLLMCard from '../AgentLLMCard'
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE'] const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL'] const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
const TABS = [
{ id: 'datasource', label: '数据源' },
{ id: 'llm', label: '大语言模型' },
{ id: 'schedules', label: '定时任务' },
{ id: 'security', label: '登录认证' },
] as const
type TabId = (typeof TABS)[number]['id']
export default function SettingsPage() { export default function SettingsPage() {
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
const [searchParams, setSearchParams] = useSearchParams()
const rawTab = searchParams.get('tab')
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
const setTab = (id: TabId) =>
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([]) const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true) const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null) const [editingKey, setEditingKey] = useState<string | null>(null)
@@ -229,12 +247,31 @@ export default function SettingsPage() {
<div className="space-y-8"> <div className="space-y-8">
<SectionHeader <SectionHeader
title="系统设置" title="系统设置"
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。" description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
/> />
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
{TABS.map(t => (
<button
key={t.id}
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
tab === t.id
? 'border-press font-bold text-press'
: 'border-transparent text-ink-500 hover:text-ink-900'
}`}
>
{t.label}
</button>
))}
</div>
{/* ── 1. 数据源 ── */} {/* ── 1. 数据源 ── */}
{tab === 'datasource' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader <CardHeader
@@ -298,10 +335,11 @@ export default function SettingsPage() {
</Card> </Card>
</div> </div>
</section> </section>
)}
{/* ── 2. LLM ── */} {/* ── 2. LLM ── */}
{tab === 'llm' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader <CardHeader
@@ -372,10 +410,11 @@ export default function SettingsPage() {
<AgentLLMCard /> <AgentLLMCard />
</div> </div>
</section> </section>
)}
{/* ── 3. 认证 ── */} {/* ── 3. 认证 ── */}
{tab === 'security' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<Card> <Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" /> <CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody> <CardBody>
@@ -413,10 +452,11 @@ export default function SettingsPage() {
</CardBody> </CardBody>
</Card> </Card>
</section> </section>
)}
{/* ── 4. 定时任务 ── */} {/* ── 4. 定时任务 ── */}
{tab === 'schedules' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<Card> <Card>
<CardHeader <CardHeader
title="采集调度" title="采集调度"
@@ -491,6 +531,7 @@ export default function SettingsPage() {
</CardBody> </CardBody>
</Card> </Card>
</section> </section>
)}
</div> </div>
) )
} }