diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 7c3d604..4d567c4 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -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(null) const [ingest, setIngest] = useState([]) const [dash, setDash] = useState(null) + const [failures, setFailures] = useState([]) + const [completeness, setCompleteness] = useState(null) + const [recentPreds, setRecentPreds] = useState>([]) 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 (
- {/* ── 工作流引导(采集 → 预测 → 评估) ── */} - + ) : ( +

+

+ )} - {/* ── 数据源健康一览 ── */} + {/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */} + {!loading && !hasMatches && ( +
+ {STEPS.map(s => ( + +
+ + {s.step} + + {s.title} + +
+

{s.desc}

+ + ))} +
+ )} + + {/* ── 数据源:单源一行即足,不装成列表 ── */} {loading ? ( -
- {[1, 2, 3].map(i => )} -
+
) : ( -
- {[ - { name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro }, - ].map(({ name, label, st }) => { - const hasData = st && st.recent_count > 0 - const keyOk = st?.key_configured !== false - return ( -
- {label} - - {hasData ? ( - <> - {st.recent_count.toLocaleString()} 条 - {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 }) : ''} - - ) : keyOk ? ( - 无数据 - ) : ( - 未配置 Key - )} - -
- ) - })} -
+ (() => { + const st = bzzoiro + const hasData = st && st.recent_count > 0 + const keyOk = st?.key_configured !== false + return ( +
+ Bzzoiro + + {hasData ? ( + <> + {st.recent_count.toLocaleString()} 条 + {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 }) : ''} + + ) : keyOk ? ( + 无数据 + ) : ( + 未配置 Key,去设置 + )} + +
+ ) + })() )}
- {/* ── 近期预测活动 ── */} + {/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */} - + {stats ? (
{stats.predictions.last_24h}
-
近 24 小时
+
预测 · 近 24 小时
{stats.predictions.last_7d}
-
近 7 天
+
预测 · 近 7 天
{stats.predictions.total}
-
累计
+
预测 · 累计
- {/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
{stats.matches?.total ?? 0}
diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index 6800bb5..94d9b55 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -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([]) const [settingsLoading, setSettingsLoading] = useState(true) const [editingKey, setEditingKey] = useState(null) @@ -229,12 +247,31 @@ export default function SettingsPage() {
+ {/* ── 分区 tab(状态在 URL 上,可深链) ── */} +
+ {TABS.map(t => ( + + ))} +
+ {/* ── 1. 数据源 ── */} + {tab === 'datasource' && (
-

数据源

+ )} {/* ── 2. LLM ── */} + {tab === 'llm' && (
-

大语言模型

+ )} {/* ── 3. 认证 ── */} + {tab === 'security' && (
-

登录认证

@@ -413,10 +452,11 @@ export default function SettingsPage() {
+ )} {/* ── 4. 定时任务 ── */} + {tab === 'schedules' && (
-

定时任务

+ )}
) }