admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史 #15
@@ -1,137 +1,199 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
||||
*
|
||||
* 展示:
|
||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||
* - 近期预测活动(24h / 7d / 总计)
|
||||
* - 快捷操作入口(带工作流引导)
|
||||
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
||||
* 而不是扫描一堆常驻数字。
|
||||
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
||||
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
||||
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
||||
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
||||
*/
|
||||
|
||||
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 { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
||||
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
||||
|
||||
/** 工作流步骤卡片 */
|
||||
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||
const STEPS = [
|
||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
|
||||
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
|
||||
{ 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() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||
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 load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const [s, i, d] = await Promise.allSettled([
|
||||
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
||||
const [s, i, d, f, c, p] = await Promise.allSettled([
|
||||
fetchAdminStats(),
|
||||
fetchIngestStatus(),
|
||||
fetchDashboard(),
|
||||
fetchIngestFailures(),
|
||||
fetchDataCompleteness(),
|
||||
fetchPredictions(100),
|
||||
])
|
||||
if (s.status === 'fulfilled') setStats(s.value)
|
||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||
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)
|
||||
}, [])
|
||||
|
||||
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 bzzoiro = sourceByName['bzzoiro']
|
||||
const hasMatches = (stats?.matches?.total ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
||||
<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}
|
||||
{/* ── 待办行:只有真有待办才出现 ── */}
|
||||
{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="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>
|
||||
<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">
|
||||
{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>
|
||||
<CardHeader
|
||||
title="数据源健康"
|
||||
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||
title="数据源"
|
||||
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
||||
/>
|
||||
<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 className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
||||
) : (
|
||||
<div>
|
||||
{[
|
||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||
].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>
|
||||
(() => {
|
||||
const st = bzzoiro
|
||||
const hasData = st && st.recent_count > 0
|
||||
const keyOk = st?.key_configured !== false
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
||||
<span className="text-xs font-medium text-ink-700">Bzzoiro</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>
|
||||
) : (
|
||||
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
||||
)}
|
||||
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 近期预测活动 ── */}
|
||||
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
||||
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
||||
<CardBody>
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
<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 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 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 className="mt-1 text-2xs text-ink-400">预测 · 累计</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchSettings, updateSetting, clearSetting,
|
||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||
@@ -26,7 +27,24 @@ import AgentLLMCard from '../AgentLLMCard'
|
||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||
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() {
|
||||
// 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 [settingsLoading, setSettingsLoading] = useState(true)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
@@ -229,12 +247,31 @@ export default function SettingsPage() {
|
||||
<div className="space-y-8">
|
||||
<SectionHeader
|
||||
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. 数据源 ── */}
|
||||
{tab === 'datasource' && (
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据源</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
@@ -298,10 +335,11 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 2. LLM ── */}
|
||||
{tab === 'llm' && (
|
||||
<section>
|
||||
<h2 className="section-head mb-3">大语言模型</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
@@ -372,10 +410,11 @@ export default function SettingsPage() {
|
||||
<AgentLLMCard />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 3. 认证 ── */}
|
||||
{tab === 'security' && (
|
||||
<section>
|
||||
<h2 className="section-head mb-3">登录认证</h2>
|
||||
<Card>
|
||||
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||
<CardBody>
|
||||
@@ -413,10 +452,11 @@ export default function SettingsPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 4. 定时任务 ── */}
|
||||
{tab === 'schedules' && (
|
||||
<section>
|
||||
<h2 className="section-head mb-3">定时任务</h2>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集调度"
|
||||
@@ -491,6 +531,7 @@ export default function SettingsPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user