diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx
index dcf30a3..1948a68 100644
--- a/frontend/src/admin/AdminLayout.tsx
+++ b/frontend/src/admin/AdminLayout.tsx
@@ -10,6 +10,32 @@ import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal'
import Login from './Login'
+import { useCommandPalette, CommandPalette } from './useCommandPalette'
+
+const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [
+ { to: '/admin', label: '仪表盘', group: '概览' },
+ { to: '/admin/collection', label: '数据采集', group: '数据流水线' },
+ { to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' },
+ { to: '/admin/predictions', label: '预测管理', group: '数据流水线' },
+ { to: '/admin/backtest', label: '回测', group: '数据流水线' },
+ { to: '/admin/monitoring', label: '监控', group: '评估与监控' },
+ { to: '/admin/eval', label: '评估', group: '评估与监控' },
+ { to: '/admin/settings', label: '设置', group: '系统' },
+ { to: '/admin/logs', label: '日志', group: '系统' },
+]
+
+// 路由 → 面包屑标签
+const ROUTE_LABELS: Record = {
+ '/admin': '仪表盘',
+ '/admin/collection': '数据采集',
+ '/admin/data-completeness': '数据完整性',
+ '/admin/predictions': '预测管理',
+ '/admin/backtest': '回测',
+ '/admin/monitoring': '监控',
+ '/admin/settings': '设置',
+ '/admin/logs': '日志',
+ '/admin/eval': '评估',
+}
const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
{
@@ -32,9 +58,8 @@ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: s
{
title: '系统设置',
items: [
- { to: '/admin/data-sources', label: '数据源', icon: '◫' },
- { to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
- { to: '/admin/config', label: '系统配置', icon: '◑' },
+ { to: '/admin/settings', label: '设置', icon: '◑' },
+ { to: '/admin/logs', label: '日志', icon: '▤' },
],
},
]
@@ -54,6 +79,7 @@ export default function AdminLayout() {
const [healthOk, setHealthOk] = useState(null)
const [authed, setAuthed] = useState(null)
const location = useLocation()
+ const palette = useCommandPalette(NAV_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
@@ -159,10 +185,10 @@ export default function AdminLayout() {
to="/admin"
end
className={({ isActive }) =>
- `flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
+ `flex min-h-[40px] items-center gap-2.5 border-l-4 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'
+ ? 'border-press bg-press-wash/60 font-medium text-press'
+ : 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
@@ -181,19 +207,13 @@ export default function AdminLayout() {
- `flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
+ `flex min-h-[40px] items-center gap-2.5 border-l-4 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'
+ ? 'border-press bg-press-wash/60 font-medium text-press'
+ : 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
-
{item.icon}
@@ -207,7 +227,7 @@ export default function AdminLayout() {
{/* 底部 */}
-
+
{/* ── 主内容区 ── */}
- {/* 报眉:日期 + 系统状态 */}
+ {/* 报眉:面包屑 + 日期 + 系统状态 */}
+ {/* 面包屑 */}
+
{dateLine()}
@@ -262,6 +293,15 @@ export default function AdminLayout() {
+
+ {/* 命令面板(⌘K) */}
+ palette.setOpen(false)}
+ />
)
}
diff --git a/frontend/src/admin/components.tsx b/frontend/src/admin/components.tsx
index 2384d0c..002e7f7 100644
--- a/frontend/src/admin/components.tsx
+++ b/frontend/src/admin/components.tsx
@@ -277,11 +277,14 @@ export function Alert({
title,
message,
onClose,
+ action,
}: {
kind: 'error' | 'ok' | 'info' | 'warning'
title: string
message?: string
onClose?: () => void
+ /** 右侧操作按钮(如「去修复」) */
+ action?: ReactNode
}) {
const style =
kind === 'error'
@@ -309,17 +312,20 @@ export function Alert({
)}
- {onClose && (
-
- )}
+
+ {action}
+ {onClose && (
+
+ )}
+
)
}
diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx
index d48baf3..4591947 100644
--- a/frontend/src/admin/pages/Collection.tsx
+++ b/frontend/src/admin/pages/Collection.tsx
@@ -4,14 +4,15 @@
* 三个采集任务:
* events — 比赛日程与比分
* standings — 联赛积分榜
- * stats — 已完赛比赛详细统计回填(xG/射门/控球等)
+ * stats — 已完赛比赛详细统计回填
* all — 依次执行以上三项
*
* 响应式布局: 移动端单列,桌面端双列
*/
-import { useEffect, useState, useCallback } from 'react'
-import { triggerCollection, fetchLeagues } from '../dal'
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { triggerCollection, fetchLeagues, fetchIngestStatus } from '../dal'
+import type { IngestSourceStatus } from '../types'
import type { CollectionRequest, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
@@ -22,19 +23,40 @@ const TASKS = [
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
] as const
+type TaskStatus = 'idle' | 'running' | 'done' | 'error'
+
export default function CollectionPage() {
const [leagues, setLeagues] = useState([])
const [task, setTask] = useState('events')
const [leagueCode, setLeagueCode] = useState('')
+
+ // 从 URL 查询参数预填充(支持从「数据完整性」页跳转)
+ useEffect(() => {
+ const sp = new URLSearchParams(window.location.search)
+ const taskParam = sp.get('task')
+ const leagueParam = sp.get('league')
+ if (taskParam && ['events', 'standings', 'stats', 'all'].includes(taskParam)) {
+ setTask(taskParam)
+ }
+ if (leagueParam) {
+ setLeagueCode(leagueParam)
+ }
+ }, [])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
- const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
+ const [ingestStatus, setIngestStatus] = useState('')
const [limit, setLimit] = useState(100)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
+ // 任务进度反馈
+ const [taskStatus, setTaskStatus] = useState('idle')
+ const [taskStartedAt, setTaskStartedAt] = useState(null)
+ const pollRef = useRef | null>(null)
+ const [ingestSnap, setIngestSnap] = useState(null)
+
const loadLeagues = useCallback(async () => {
const lg = await fetchLeagues()
setLeagues(lg)
@@ -42,6 +64,24 @@ export default function CollectionPage() {
useEffect(() => { loadLeagues() }, [loadLeagues])
+ // 轮询采集状态(任务启动后)
+ const startPolling = useCallback(() => {
+ if (pollRef.current) clearInterval(pollRef.current)
+ pollRef.current = setInterval(async () => {
+ try {
+ const { sources } = await fetchIngestStatus()
+ const bz = sources.find(s => s.name === 'bzzoiro')
+ if (bz) setIngestSnap(bz)
+ } catch { /* ignore */ }
+ }, 5_000)
+ }, [])
+
+ const stopPolling = useCallback(() => {
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
+ }, [])
+
+ useEffect(() => () => stopPolling(), [stopPolling])
+
const isEventsTask = task === 'events' || task === 'all'
async function handleSubmit(e: React.FormEvent) {
@@ -49,6 +89,8 @@ export default function CollectionPage() {
setError(null)
setResult(null)
setLoading(true)
+ setTaskStatus('running')
+ setTaskStartedAt(Date.now())
try {
const body: CollectionRequest = {
@@ -58,7 +100,6 @@ export default function CollectionPage() {
limit,
season: season || undefined,
status: ingestStatus || undefined,
- // 仅 events/all 任务生效
date_from: isEventsTask ? dateFrom || undefined : undefined,
date_to: isEventsTask ? dateTo || undefined : undefined,
}
@@ -67,13 +108,24 @@ export default function CollectionPage() {
title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
})
+ // 启动轮询,跟踪状态
+ startPolling()
+ // 30 秒后自动停止轮询并标记完成
+ setTimeout(() => {
+ setTaskStatus('done')
+ stopPolling()
+ }, 30_000)
} catch (err: unknown) {
+ setTaskStatus('error')
setError(err instanceof Error ? err.message : '采集触发失败')
+ stopPolling()
} finally {
setLoading(false)
}
}
+ const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
+
return (
- {/* 数据源说明 */}
-
-
-
-
- {TASKS.map(t => (
-
-
-
{t.icon}
-
{t.label}
-
{t.desc}
+ {/* 任务状态 + 数据源说明 */}
+
+ {/* 任务进度 */}
+
+
+
+ {taskStatus === 'idle' && (
+ 尚未触发任务。
+ )}
+ {taskStatus === 'running' && (
+
+
+
+ 任务执行中,已运行 {elapsed}s…
+
+ 后台异步执行,关闭页面不影响结果。可稍后查看「系统日志」确认完成。
+
- ))}
-
+ )}
+ {taskStatus === 'done' && (
+
+
+
+ 任务已提交,后台执行中(可能尚未完成)
+
+
+ 采集耗时取决于数据量。请到「系统日志」页查看最终结果。
+
+
+ )}
+ {taskStatus === 'error' && (
+
任务触发失败,请检查配置或网络。
+ )}
+ {ingestSnap?.last_success_at && (
+
+
+ bzzoiro 最近一次采集: {new Date(ingestSnap.last_success_at).toLocaleString('zh-CN', { hour12: false })}
+
+
+ )}
+
+
-
- 采集接口需要管理员登录(401 表示登录已过期)。
- 各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
- 「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
-
-
-
+ {/* 数据源说明 */}
+
+
+
+
+ {TASKS.map(t => (
+
+
+
{t.icon}
+
{t.label}
+
{t.desc}
+
+
+ ))}
+
+
+
+ 采集接口需要管理员登录(401 表示登录已过期)。
+ 各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
+ 「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
+
+
+
+
)
diff --git a/frontend/src/admin/pages/DataCompleteness.tsx b/frontend/src/admin/pages/DataCompleteness.tsx
index 685e7cb..bd5fa2c 100644
--- a/frontend/src/admin/pages/DataCompleteness.tsx
+++ b/frontend/src/admin/pages/DataCompleteness.tsx
@@ -31,6 +31,25 @@ function pctColor(pct: number): string {
return 'bg-rose-500'
}
+/** 根据问题描述生成可操作的修复链接 */
+function getIssueAction(issue: string): { href: string; label: string } | null {
+ // 提取联赛代码(大写字母+数字,如 E0, SP1)
+ const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
+ const code = codeMatch ? codeMatch[1] : ''
+ if (!code) return null
+
+ if (issue.includes('无已完赛比赛')) {
+ return { href: `/admin/collection?task=events&league=${code}`, label: '去采集' }
+ }
+ if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
+ return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填' }
+ }
+ if (issue.includes('无积分榜')) {
+ return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集' }
+ }
+ return null
+}
+
export default function DataCompletenessPage() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
@@ -95,19 +114,27 @@ export default function DataCompletenessPage() {
- {/* 健康问题 */}
+ {/* 健康问题(可操作) */}
- {data.issues.map((issue, i) => (
-
- ))}
+ {data.issues.map((issue, i) => {
+ const action = getIssueAction(issue)
+ return (
+
+ {action.label}
+
+ ) : undefined}
+ />
+ )
+ })}
diff --git a/frontend/src/admin/pages/DataSources.tsx b/frontend/src/admin/pages/DataSources.tsx
index 04f290b..25b1530 100644
--- a/frontend/src/admin/pages/DataSources.tsx
+++ b/frontend/src/admin/pages/DataSources.tsx
@@ -179,6 +179,7 @@ export default function DataSourcesPage() {
}
async function handleResetCooldown() {
+ if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
diff --git a/frontend/src/admin/pages/EvalPage.tsx b/frontend/src/admin/pages/EvalPage.tsx
index adb1e8e..812b845 100644
--- a/frontend/src/admin/pages/EvalPage.tsx
+++ b/frontend/src/admin/pages/EvalPage.tsx
@@ -155,7 +155,7 @@ export default function EvalPage() {
0 ? `共 ${summary.length} 组` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
/>
{loading ? (
@@ -165,6 +165,7 @@ export default function EvalPage() {
) : summary.length === 0 ? (
) : (
+
`${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据"
/>
+
)}
diff --git a/frontend/src/admin/pages/Logs.tsx b/frontend/src/admin/pages/Logs.tsx
index 6a75cb2..d8d2328 100644
--- a/frontend/src/admin/pages/Logs.tsx
+++ b/frontend/src/admin/pages/Logs.tsx
@@ -52,6 +52,24 @@ export default function LogsPage() {
load()
}, [load])
+ const scrollRef = useRef(null)
+ const userScrolledUp = useRef(false)
+
+ // 检测用户是否向上滚动过
+ const handleScroll = () => {
+ const el = scrollRef.current
+ if (!el) return
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
+ userScrolledUp.current = !atBottom
+ }
+
+ // 加载后自动滚动到底部(仅当用户未向上滚动时)
+ useEffect(() => {
+ if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+ }
+ }, [entries, autoRefresh])
+
// 自动刷新
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current)
@@ -123,7 +141,7 @@ export default function LogsPage() {
) : entries.length === 0 ? (
暂无匹配的日志
) : (
-
+
{entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return (
diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx
index b502f5d..22fd0b5 100644
--- a/frontend/src/admin/pages/Predictions.tsx
+++ b/frontend/src/admin/pages/Predictions.tsx
@@ -41,6 +41,7 @@ export default function PredictionsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState
(null)
const [successMsg, setSuccessMsg] = useState(null)
+ const [leagueFilter, setLeagueFilter] = useState('') // 联赛筛选
// 结算表单
const [settleId, setSettleId] = useState('')
@@ -56,7 +57,8 @@ export default function PredictionsPage() {
useEffect(() => {
refreshPredictions()
- fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
+ // 只加载未开赛的比赛(用于预测)
+ fetchMatches({ status: 'scheduled', limit: 200 }).then(d => setMatches(d.items))
}, [refreshPredictions])
/** match_id → 中文名对阵 */
@@ -72,6 +74,15 @@ export default function PredictionsPage() {
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
+ // 联赛列表(从比赛中提取) + 筛选后的比赛
+ const leagues = useMemo(() => {
+ const set = new Set(matches.map(m => m.league_code).filter(Boolean))
+ return [...set].sort()
+ }, [matches])
+ const filteredMatches = leagueFilter
+ ? matches.filter(m => m.league_code === leagueFilter)
+ : matches
+
async function handlePredict(e: React.FormEvent) {
e.preventDefault()
if (!matchId) return
@@ -131,21 +142,38 @@ export default function PredictionsPage() {