admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史 #15

Merged
shangfangjian merged 5 commits from admin-ux-overhaul into main 2026-09-22 13:34:38 +08:00
2 changed files with 180 additions and 77 deletions
Showing only changes of commit 0ab838258c - Show all commits
+105 -43
View File
@@ -1,56 +1,127 @@
/** /**
* 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">
{/* ── 工作流引导(采集 → 预测 → 评估) ── */} {/* ── 待办行:只有真有待办才出现 ── */}
{loading ? (
<SkeletonBlock className="h-14 w-full" />
) : todos.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-3">
{todos.map(t => (
<Link
key={t.key}
to={t.to}
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
>
<span className="font-serif text-2xl font-bold tabular-nums text-press">
{t.count}{t.approx ? '+' : ''}
</span>
<span className="text-xs text-ink-700">{t.label}</span>
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true"></span>
</Link>
))}
</div>
) : (
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
<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"> <div className="grid gap-4 sm:grid-cols-3">
{STEPS.map((s, i) => ( {STEPS.map(s => (
<a <Link
key={s.to} key={s.to}
href={s.to} to={s.to}
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100" 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"> <div className="flex items-center gap-2.5">
@@ -61,32 +132,28 @@ export default function Dashboard() {
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span> <span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span>
</div> </div>
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p> <p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
{i < STEPS.length - 1 && <span className="sr-only"></span>} </Link>
</a>
))} ))}
</div> </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 },
].map(({ name, label, st }) => {
const hasData = st && st.recent_count > 0 const hasData = st && st.recent_count > 0
const keyOk = st?.key_configured !== false const keyOk = st?.key_configured !== false
return ( 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"> <div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
<span className="text-xs font-medium text-ink-700">{label}</span> <span className="text-xs font-medium text-ink-700">Bzzoiro</span>
<span className="flex items-center gap-3 text-2xs"> <span className="flex items-center gap-3 text-2xs">
{hasData ? ( {hasData ? (
<> <>
@@ -96,42 +163,37 @@ export default function Dashboard() {
) : keyOk ? ( ) : keyOk ? (
<span className="text-ink-400"></span> <span className="text-ink-400"></span>
) : ( ) : (
<span className="text-press"> Key</span> <Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2"> Key,</Link>
)} )}
<span <span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
/>
</span> </span>
</div> </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>
) )
} }