From 1cdc6411f514524f757b88c0026e21f976a87fd1 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:33:15 +0800 Subject: [PATCH 1/5] =?UTF-8?q?chore(admin):=20=E5=88=A0=E9=99=A4=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=90=8E=E9=81=97=E7=95=99=E7=9A=84=204=20=E4=B8=AA?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E9=A1=B5=E9=9D=A2(~1280=20=E8=A1=8C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataSources/LLMConfig/Config 已合并进统一 Settings 页, Predictions 已被 PredictionHistory 取代,均无任何路由或 import 引用。 --- frontend/src/admin/pages/Config.tsx | 267 ---------------- frontend/src/admin/pages/DataSources.tsx | 376 ----------------------- frontend/src/admin/pages/LLMConfig.tsx | 275 ----------------- frontend/src/admin/pages/Predictions.tsx | 363 ---------------------- 4 files changed, 1281 deletions(-) delete mode 100644 frontend/src/admin/pages/Config.tsx delete mode 100644 frontend/src/admin/pages/DataSources.tsx delete mode 100644 frontend/src/admin/pages/LLMConfig.tsx delete mode 100644 frontend/src/admin/pages/Predictions.tsx diff --git a/frontend/src/admin/pages/Config.tsx b/frontend/src/admin/pages/Config.tsx deleted file mode 100644 index b0e69ba..0000000 --- a/frontend/src/admin/pages/Config.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Admin 后台 - 系统配置管理页面(报刊风) - * - * 功能: - * - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie) - * - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明) - * - 配置修改指南 - */ - -import { useEffect, useState, useCallback } from 'react' -import { fetchSystemConfig } from '../dal' -import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api' -import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' - -export default function ConfigPage() { - const [config, setConfig] = useState([]) - const [loading, setLoading] = useState(true) - const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null) - - // 修改密码表单 - const [currentPwd, setCurrentPwd] = useState('') - const [newPwd, setNewPwd] = useState('') - const [confirmPwd, setConfirmPwd] = useState('') - const [pwdBusy, setPwdBusy] = useState(false) - const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null) - - const loadConfig = useCallback(async () => { - setLoading(true) - try { - const data = await fetchSystemConfig() - setConfig(data) - } catch { - setConfig([]) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadConfig() - fetchAuthState() - .then(s => setPasswordOrigin(s.password_origin ?? null)) - .catch(() => setPasswordOrigin(null)) - }, [loadConfig]) - - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault() - setPwdNotice(null) - if (newPwd !== confirmPwd) { - setPwdNotice({ ok: false, text: '两次输入的新密码不一致' }) - return - } - setPwdBusy(true) - try { - const res = await changePassword(currentPwd, newPwd) - setPwdNotice({ ok: true, text: res.message }) - // 密码即会话密钥,修改后所有会话失效:主动切回登录页 - setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500) - } catch (err) { - setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' }) - } finally { - setPwdBusy(false) - } - } - - return ( -
- - - {/* 登录与鉴权 */} - - - - -

- 密码初始来自服务器 .env 的{' '} - ADMIN_PASSWORD(启动时自动转为哈希),在下方修改后以{' '} - scrypt 哈希安全存入数据库并立即生效,明文不再留存。 - 密码即会话签名密钥,修改后所有已登录会话失效,需用新密码重新登录。 - 脚本直连接口可改用 ADMIN_API_KEY(请求头 X-API-Key)。 -

- {passwordOrigin && ( -

- 当前密码来源: - {passwordOrigin === 'db' ? ( - 数据库(scrypt 哈希) - ) : passwordOrigin === 'env' ? ( - .env 初始值 - ) : ( - 未配置 - )} -

- )} - - {/* 修改密码表单 */} -
-
-
- - setCurrentPwd(e.target.value)} - autoComplete="current-password" - className="field w-full" - /> -
-
- - setNewPwd(e.target.value)} - autoComplete="new-password" - className="field w-full" - /> -
-
- - setConfirmPwd(e.target.value)} - autoComplete="new-password" - className="field w-full" - /> -
-
- - {pwdNotice && ( - - )} - -
-

修改成功后会自动退出登录,请用新密码重新登录。

- -
- -
-
- - {/* 快速导航 */} -
- -
-
数据源配置
-
管理采集源 API Key
-
- -
- -
-
LLM 配置
-
管理模型连接与统计
-
- -
-
- - {/* 配置列表 */} - - - {loading ? (<> 加载中) : '刷新'} - - } - /> - - {loading ? ( -
- {[1, 2, 3, 4, 5].map(i => ( - - ))} -
- ) : config.length > 0 ? ( -
- {config.map(item => ( -
-
- {item.key} - {item.is_sensitive && 敏感} -
-
{item.value_masked}
-
{item.description}
-
- ))} -
- ) : ( -

无法加载配置信息

- )} -
-
- - {/* 修改指南 */} - - - -
-

通过 SSH 修改 .env

-
-{`# 连接到部署主机
-ssh user@your-server-ip
-
-# 进入项目目录
-cd /vol2/1000/Docker/Profeto
-
-# 编辑 .env 文件
-nano .env
-
-# 修改后重启后端服务
-docker compose restart api
-
-# 查看日志确认生效
-docker compose logs -f api`}
-            
-
- -
-

常用配置项说明

-
- {[ - ['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'], - ['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'], - ['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'], - ['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'], - ['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'], - ['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'], - ['DATABASE_URL', 'PostgreSQL 数据库连接字符串'], - ].map(([key, desc]) => ( -
- - {key} - - {desc} -
- ))} -
-
-
-
-
- ) -} diff --git a/frontend/src/admin/pages/DataSources.tsx b/frontend/src/admin/pages/DataSources.tsx deleted file mode 100644 index 25b1530..0000000 --- a/frontend/src/admin/pages/DataSources.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { useEffect, useState, useCallback } from 'react' -import { - fetchDataSourceStatuses, - fetchIngestStatus, - fetchAdminStats, - fetchKeyRingStatus, - resetKeyRingCooldown, - updateSetting, - clearSetting, - testDataSourceConnection, -} from '../dal' -import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types' -import type { KeyRingStatusResponse } from '../dal' -import SettingRow from '../SettingRow' -import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' - -function formatTime(iso: string | null): string { - if (!iso) return '暂无记录' - try { - return new Date(iso).toLocaleString('zh-CN', { hour12: false }) - } catch { - return iso - } -} - -export default function DataSourcesPage() { - const [sources, setSources] = useState([]) - const [loading, setLoading] = useState(true) - const [loadError, setLoadError] = useState('') - const [ingestStats, setIngestStats] = useState>({}) - const [ingestLoading, setIngestLoading] = useState(true) - const [stats, setStats] = useState(null) - - const [testingSource, setTestingSource] = useState(null) - const [testResults, setTestResults] = useState>({}) - - const [editingKey, setEditingKey] = useState(null) - const [busyKey, setBusyKey] = useState(null) - const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null) - const [keyRing, setKeyRing] = useState(null) - const [ringLoading, setRingLoading] = useState(false) - - const loadSources = useCallback(async () => { - setLoading(true) - setLoadError('') - try { - setSources(await fetchDataSourceStatuses()) - } catch (err) { - setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败') - setSources([]) - } finally { - setLoading(false) - } - }, []) - - // 健康状态(只读,与配置加载并行;失败不阻塞配置页) - const loadIngest = useCallback(async () => { - setIngestLoading(true) - try { - const { sources } = await fetchIngestStatus() - setIngestStats(Object.fromEntries(sources.map(x => [x.name, x]))) - } catch { - /* ignore */ - } finally { - setIngestLoading(false) - } - }, []) - - // 管理区统计(只读) - const loadStats = useCallback(async () => { - try { - setStats(await fetchAdminStats()) - } catch { - /* ignore */ - } - }, []) - - // Key Ring 状态(只读) - const loadKeyRing = useCallback(async () => { - setRingLoading(true) - try { - setKeyRing(await fetchKeyRingStatus()) - } catch { - /* ignore */ - } finally { - setRingLoading(false) - } - }, []) - - useEffect(() => { - loadSources() - loadIngest() - loadStats() - loadKeyRing() - }, [loadSources, loadIngest, loadStats, loadKeyRing]) - - async function handleTest(sourceName: string) { - setTestingSource(sourceName) - setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } })) - try { - const result = await testDataSourceConnection(sourceName) - setTestResults(prev => ({ ...prev, [sourceName]: result })) - } catch (err: unknown) { - const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败' - setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } })) - } finally { - setTestingSource(null) - } - } - - // 渲染数据源健康块(最近采集 + 异常提示) - function renderHealth(sourceName: string) { - const st = ingestStats[sourceName] - if (!st) { - return ( -
- 最近成功采集 - {ingestLoading ? '加载中...' : '暂无数据'} -
- ) - } - const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录' - const issues: string[] = [] - if (st.status === 'key_not_configured') issues.push('未配置 API Key') - else if (st.status === 'no_data') issues.push('本地无数据,建议补采') - if (st.last_failure) issues.push('近期有采集失败') - return ( -
-
- 最近成功采集 - {ago} -
-
- 已入库(近期) - {st.recent_count.toLocaleString()} 条 -
- {st.note &&

{st.note}

} - {issues.length > 0 && ( -

- {issues.join(' / ')} — 请前往「数据采集」补采 -

- )} - {st.last_failure && ( -

- 最近失败: {st.last_failure.detail.slice(0, 60)} -

- )} -
- ) - } - - async function handleSave(key: string, value: string) { - setBusyKey(key) - setRowNotice(null) - try { - await updateSetting(key, value) - setRowNotice({ key, ok: true, text: '已保存,立即生效' }) - setEditingKey(null) - await loadSources() - } catch (err) { - setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' }) - } finally { - setBusyKey(null) - } - } - - async function handleClear(key: string) { - setBusyKey(key) - setRowNotice(null) - try { - await clearSetting(key) - setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' }) - await loadSources() - } catch (err) { - setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' }) - } finally { - setBusyKey(null) - } - } - - async function handleResetCooldown() { - if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return - try { - const res = await resetKeyRingCooldown() - setKeyRing(res.stats) - setRowNotice({ key: "__ring", ok: true, text: res.message }) - } catch (err) { - setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' }) - } - } - - - return ( -
- - - {loadError && ( - - )} - - {/* 数据源卡片 */} - {loading ? ( -
- {[1, 2, 3].map(i => ( - - -
- - - -
-
-
- ))} -
- ) : ( -
- {sources.map(source => { - const result = testResults[source.name] - const cardKeys = source.settings.map(s => s.key) - return ( - - -
-

{source.label}

- - {source.key_configured ? '已就绪' : '缺配置'} - -
- -

{source.description}

- - {source.settings.length > 0 ? ( -
- {source.settings.map(setting => ( - { setEditingKey(setting.key); setRowNotice(null) }} - onCancel={() => setEditingKey(null)} - onSave={v => handleSave(setting.key, v)} - onClear={() => handleClear(setting.key)} - /> - ))} -
- ) : ( -

无需 API Key

- )} - - {rowNotice && cardKeys.includes(rowNotice.key) && ( - - )} - - {/* 数据源健康:最近采集 + 异常提示 */} - {renderHealth(source.name)} - - {result && testingSource !== source.name && ( - - )} - - -
-
- ) - })} -
- )} - - {/* API Key 轮换环状态 */} - - - 重置冷却 - - } - /> - - {ringLoading && !keyRing ? ( - - ) : keyRing && keyRing.total > 0 ? ( -
- {keyRing.keys.map((k, i) => { - const isBlocked = k.blocked_remaining > 0 - return ( -
-
- - {k.masked} - {i === keyRing.active_index && ( - 当前 - )} -
- - {isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'} - -
- ) - })} -
- ) : ( -

暂无 key 配置

- )} -

- 在「Bzzoiro」配置项中用逗号 / 分号 / 换行分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key; - 全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。 -

-
-
- - {/* 近期活动统计(只读) */} - {stats && stats.predictions && ( - - - -
-
-
{stats.predictions.last_24h}
-
近 24 小时
-
-
-
{stats.predictions.last_7d}
-
近 7 天
-
-
-
{stats.predictions.total}
-
总计
-
-
-
-
- )} - - - - -
-

- 保存的配置存于数据库 app_settings立即生效,优先于 .env;「回落 .env」删除覆盖值。 -

-

- 「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。 -

-

- 「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。 -

-
-
-
-
- ) -} diff --git a/frontend/src/admin/pages/LLMConfig.tsx b/frontend/src/admin/pages/LLMConfig.tsx deleted file mode 100644 index a61ed77..0000000 --- a/frontend/src/admin/pages/LLMConfig.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Admin 后台 - LLM 配置管理页面(报刊风) - * - * 功能: - * - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定) - * - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用) - * - 显示 LLM 使用统计(从预测记录聚合) - * - 可用模型列表 - */ - -import { useEffect, useState, useCallback } from 'react' -import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal' -import type { LLMUsageStats } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' -import SettingRow from '../SettingRow' -import AgentLLMCard from '../AgentLLMCard' -import type { DataSourceSetting } from '../types' - -const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL'] - -export default function LLMConfigPage() { - const [stats, setStats] = useState(null) - const [loading, setLoading] = useState(true) - const [testing, setTesting] = useState(false) - const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) - - // LLM 连接配置(运行时配置,DB 覆盖 .env) - const [llmSettings, setLlmSettings] = useState([]) - const [settingsLoading, setSettingsLoading] = useState(true) - const [editingKey, setEditingKey] = useState(null) - const [busyKey, setBusyKey] = useState(null) - const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null) - - const loadStats = useCallback(async () => { - setLoading(true) - try { - const data = await fetchLLMUsageStats() - setStats(data) - } catch { - setStats(null) - } finally { - setLoading(false) - } - }, []) - - const loadSettings = useCallback(async () => { - setSettingsLoading(true) - try { - const all = await fetchSettings() - setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key))) - } catch { - setLlmSettings([]) - } finally { - setSettingsLoading(false) - } - }, []) - - useEffect(() => { - loadStats() - loadSettings() - }, [loadStats, loadSettings]) - - /** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */ - const detectLLMModels = useCallback(async (): Promise => { - const r = await fetchLLMModels() - if (!r.ok) throw new Error(r.detail) - return r.models - }, []) - - async function handleSave(key: string, value: string) { - setBusyKey(key) - setRowNotice(null) - try { - await updateSetting(key, value) - setRowNotice({ key, ok: true, text: '已保存,立即生效' }) - setEditingKey(null) - await loadSettings() - } catch (err) { - setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' }) - } finally { - setBusyKey(null) - } - } - - async function handleClear(key: string) { - setBusyKey(key) - setRowNotice(null) - try { - await clearSetting(key) - setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' }) - await loadSettings() - } catch (err) { - setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' }) - } finally { - setBusyKey(null) - } - } - - async function handleTest() { - setTesting(true) - setTestResult(null) - try { - await testLLMConnection() - setTestResult({ success: true, message: 'LLM 连接测试成功' }) - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'LLM 连接测试失败' - setTestResult({ success: false, message: msg }) - } finally { - setTesting(false) - } - } - - return ( -
- - -
- {/* LLM 连接配置 */} - - - {settingsLoading ? (<> 加载中) : '刷新'} - - } - /> - - {settingsLoading ? ( -
- {[1, 2, 3].map(i => )} -
- ) : ( -
- {llmSettings.map(setting => ( - { - setEditingKey(setting.key) - setRowNotice(null) - }} - onCancel={() => setEditingKey(null)} - onSave={v => handleSave(setting.key, v)} - onClear={() => handleClear(setting.key)} - detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined} - /> - ))} -
- )} - - {rowNotice && ( -
- -
- )} - -

- 模式: 多专家 (5 路 + 终裁)。填入可连通的 OpenAI 兼容服务(如 DeepSeek、 - 智谱、通义或任意网关)后点下方「测试 LLM 连接」验证。 -

- - {/* 测试连接 */} - {testResult && ( -
- -
- )} - - -

- 测试会真实调用一次 LLM 预测,产生费用。 -

-
-
- - {/* 使用统计 */} - - - {loading ? (<> 加载中) : '刷新'} - - } - /> - - {loading ? ( -
- - -
- ) : stats ? ( -
-
-
- {stats.total_predictions} -
-
总预测数
-
-
-
- {stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'} -
-
平均延迟
-
-
-
- {stats.success_rate.toFixed(0)}% -
-
有效率
-
-
- ) : ( -

暂无使用统计数据

- )} -
-
-
- - {/* 专家与终裁独立配置 */} - - - {/* 最近预测 */} - - - - {loading ? ( -
- {[1, 2, 3].map(i => ( - - ))} -
- ) : stats && stats.recent_predictions.length > 0 ? ( -
- {stats.recent_predictions.map(p => ( -
-
- #{p.id} - 比赛 #{p.match_id} - {p.model} -
-
- - {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} - - {p.status === 'success' ? 成功 : 失败} -
-
- ))} -
- ) : ( -

暂无预测记录

- )} -
-
-
- ) -} diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx deleted file mode 100644 index 1851ba1..0000000 --- a/frontend/src/admin/pages/Predictions.tsx +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Admin 后台 - 预测管理页面(报刊风) - * - * 功能: - * - 触发预测(选择比赛 + 模式) - * - 结算:录入实际比分,写入评估(接 /eval/settle) - * - 预测记录列表:可展开查看终裁理由与专家摘要 - * - * 响应式布局: 移动端单列,桌面端双列 - */ - -import { useCallback, useEffect, useMemo, useState } from 'react' -import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal' -import type { Match, Prediction } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' -import { teamSidePrefix } from '../../components/TeamSideTag' -import { AgentWeightsBar } from '../components' - -const AGENT_LABELS: Record = { - h2h: '历史交锋分析专家', - form: '近期状态分析专家', - stats: '攻防数据分析专家', - home_away: '主客因素分析专家', - injuries: '阵容完整性分析专家', -} - -const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } - -function fmtTime(s?: string | null): string { - if (!s) return '—' - const d = new Date(s) - return isNaN(d.getTime()) - ? s - : d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) -} - -export default function PredictionsPage() { - const [matches, setMatches] = useState([]) - const [predictions, setPredictions] = useState([]) - const [matchId, setMatchId] = useState('') - const [mode, setMode] = useState<'single' | 'multi'>('multi') - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [successMsg, setSuccessMsg] = useState(null) - - // 结算表单 - const [settleId, setSettleId] = useState('') - const [homeGoals, setHomeGoals] = useState('') - const [awayGoals, setAwayGoals] = useState('') - const [settling, setSettling] = useState(false) - const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null) - - const refreshPredictions = useCallback(async () => { - const list = await fetchPredictions(50) - setPredictions(list) - }, []) - - useEffect(() => { - refreshPredictions() - fetchMatches({ limit: 100 }).then(d => setMatches(d.items)) - }, [refreshPredictions]) - - /** match_id → 中文名对阵 */ - const matchName = useMemo(() => { - const map = new Map() - for (const m of matches) { - const home = m.home_team_zh || m.home_team - const away = m.away_team_zh || m.away_team - map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`) - } - return map - }, [matches]) - - const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}` - - async function handlePredict(e: React.FormEvent) { - e.preventDefault() - if (!matchId) return - setLoading(true) - setError(null) - setSuccessMsg(null) - try { - await triggerPrediction({ match_id: parseInt(matchId), mode }) - setSuccessMsg('预测任务已完成,记录已更新') - await refreshPredictions() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : '预测失败') - } finally { - setLoading(false) - } - } - - const unsettled = predictions.filter(p => !p.settled) - - async function handleSettle(e: React.FormEvent) { - e.preventDefault() - const pid = parseInt(settleId) - const hg = parseInt(homeGoals) - const ag = parseInt(awayGoals) - if (!pid || isNaN(hg) || isNaN(ag)) return - setSettling(true) - setSettleMsg(null) - try { - await settlePrediction(pid, hg, ag) - setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' }) - setSettleId('') - setHomeGoals('') - setAwayGoals('') - await refreshPredictions() - } catch (err: unknown) { - setSettleMsg({ - kind: 'error', - text: err instanceof Error ? err.message : '结算失败', - }) - } finally { - setSettling(false) - } - } - - const settleTarget = predictions.find(p => p.id === parseInt(settleId)) - - return ( -
- - -
- {/* 新建预测 */} - - - -
-
- - -
- -
- - -
- - {error && setError(null)} />} - {successMsg && ( - setSuccessMsg(null)} /> - )} - - - -
-
- - {/* 结算 */} - - - - {unsettled.length === 0 ? ( -

- 没有待结算的预测记录。预测完成后可在此录入实际比分。 -

- ) : ( -
-
- - -
- - {settleTarget && ( -

- 预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'} - ({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'}) - {fmtTime(settleTarget.created_at)} -

- )} - -
-
- - setHomeGoals(e.target.value)} - className="field w-full" - /> -
-
- - setAwayGoals(e.target.value)} - className="field w-full" - /> -
-
- - {settleMsg && ( - setSettleMsg(null)} - /> - )} - - - - )} -
-
-
- - {/* 最近预测 */} - - - - {predictions.length === 0 ? ( -

- 暂无预测记录,触发预测后将在此显示 -

- ) : ( -
- {predictions.map(p => { - const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok') - return ( -
- - #{p.id} - {nameOf(p.match_id)} - - {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'} - - - {OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} - {p.subjective_confidence !== null && p.subjective_confidence !== undefined && - ` · ${Math.round(p.subjective_confidence * 100)}%`} - - - {p.settled ? ( - 已结算 - ) : ( - 未结算 - )} - {p.status === 'degraded' && ( - 降级·仅供参考 - )} - {p.status === 'failed' && ( - 预测失败 - )} - {fmtTime(p.created_at)} - - - - -
-

- {p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'} - {p.model && {p.model}} -

- - {p.reasoning && ( -
-

- {p.reasoning} -

-
- )} - - {p.agent_weights && Object.keys(p.agent_weights).length > 0 && ( - - )} - - {p.agent_outputs && p.agent_outputs.length > 0 && ( -
    - {p.agent_outputs.map((a, i) => ( -
  • -
  • - ))} -
- )} - - {p.settled && ( -

- 实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'} -

- )} -
-
- ) - })} -
- )} -
-
-
- ) -} From 9cedb874f70c761405172104fad0305abd4d669a Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:34:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(admin):=20=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=8C=89=E5=BF=83=E6=99=BA=E6=A8=A1=E5=9E=8B=E4=B8=89=E7=BB=84?= =?UTF-8?q?=E9=87=8D=E6=8E=92(=E6=95=B0=E6=8D=AE/=E9=A2=84=E6=B5=8B/?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预测历史/回测/评估移出「数据流水线」,组成「预测」组(生命周期闭环) - 「评估与监控」拆开:评估归预测,监控归系统 - 删除 SIDEBAR_GROUP_TITLES 别名映射(分组名与面板名统一) - 预测历史图标改 target(靶心),数据完整性改 eval(核对文档),不再与仪表盘共用 chart --- frontend/src/admin/nav.ts | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/frontend/src/admin/nav.ts b/frontend/src/admin/nav.ts index 7b67e7d..91af3c5 100644 --- a/frontend/src/admin/nav.ts +++ b/frontend/src/admin/nav.ts @@ -20,18 +20,25 @@ export interface NavItem { hideFromSidebar?: boolean } -/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */ +/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 + * + * 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。 + * 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。 + */ export const NAV_ITEMS: NavItem[] = [ { to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' }, - { to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' }, - { to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' }, - { to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true }, - { to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' }, - { to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' }, - { to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' }, - { to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' }, - { to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' }, + // ── 数据:采集、核对、管线内部视图 ── + { to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' }, + { to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' }, + { to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true }, + // ── 预测:历史 → 回测 → 评估闭环 ── + { to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' }, + { to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' }, + { to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' }, + // ── 系统:活着吗、发生了什么、怎么调 ── + { to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' }, { to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' }, + { to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' }, ] /** 命令面板条目(原 NAV_PAGES 的唯一来源) */ @@ -42,11 +49,6 @@ export const ROUTE_LABELS: Record = Object.fromEntries( NAV_ITEMS.map(i => [i.to, i.label]), ) -/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */ -const SIDEBAR_GROUP_TITLES: Record = { - 系统: '系统设置', -} - /** * 侧栏分组(原 NAV_SECTIONS 的唯一来源)。 * 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。 @@ -55,13 +57,12 @@ export const NAV_SECTIONS = (() => { const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览') const titles: string[] = [] for (const i of sidebarItems) { - const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group - if (!titles.includes(title)) titles.push(title) + if (!titles.includes(i.group)) titles.push(i.group) } return titles.map(title => ({ title, items: sidebarItems - .filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title) + .filter(i => i.group === title) .map(({ to, label, icon }) => ({ to, label, icon })), })) })() From 0ab838258c76cd65945cede59603be3df34b457f Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:44:12 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(admin):=20Settings=20=E5=88=86?= =?UTF-8?q?=E5=8C=BA=20tab=20=E5=8C=96=20+=20Dashboard=20=E6=94=B9?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings(496 行五块内容单页)拆为 4 个 tab:数据源/LLM/定时任务/登录认证; tab 状态写入 URL(?tab=llm)可深链;数据加载与 API 调用不变 - Dashboard 改待办驱动:死信待处理/联赛数据缺口/可结算预测, 有待办才显示,每项直达处理页;全部清零时显示状态行 - 三步工作流卡仅在库里无比赛时显示(首次引导,老用户不再占版面) - 修文案漂移:「数据源健康」卡不再引用已不存在的「数据源」页; 「运行预测」链接语义修正(预测在前台比赛详情页发起) - 数据源单源硬编码列表简化为一行;未配 Key 直达设置分区 - 全改 ,不再整页刷新 --- frontend/src/admin/pages/Dashboard.tsx | 206 ++++++++++++++++--------- frontend/src/admin/pages/Settings.tsx | 51 +++++- 2 files changed, 180 insertions(+), 77 deletions(-) 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' && (
-

定时任务

+ )}
) } From d013407aa10c6503fb5b398c9281de12c97d8925 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:44:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(admin):=20=E9=87=87=E9=9B=86=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=8E=86=E5=8F=B2=E5=88=97=E8=A1=A8=20+=20=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E8=AF=AD=E8=A8=80=E7=BB=9F=E4=B8=80=20+=20=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E6=A0=B7=E5=BC=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dal 新增 fetchIngestJobs(接已有 GET /admin/ingest/jobs,此前前端未用) - Collection 新增「最近任务」卡:最新 10 条,状态点/任务类型/结果摘要, 任务终态后自动刷新 —— 补齐采集失败后的回看入口 - 任务选择按钮修复:brand-* 色在 tailwind 中不存在(选中态样式失效), 改用全站 press/press-wash;去掉与报刊风方角语言不符的 rounded-lg - 任务图标 ⚽🏆📊 → ◈◇▤⇉,与全站几何字符一致,不混用 emoji - Logs 页描述补充 LOG_FILE 持久化边界说明 --- frontend/src/admin/dal.ts | 11 +++++ frontend/src/admin/pages/Collection.tsx | 63 +++++++++++++++++++++---- frontend/src/admin/pages/Logs.tsx | 2 +- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/frontend/src/admin/dal.ts b/frontend/src/admin/dal.ts index 90cc311..d4cf05c 100644 --- a/frontend/src/admin/dal.ts +++ b/frontend/src/admin/dal.ts @@ -370,6 +370,17 @@ export function fetchIngestJob(jobId: string): Promise { return api.get(`${API_BASE}/admin/ingest/jobs/${jobId}`) } +/** + * 采集任务历史列表(GET /admin/ingest/jobs,最新在前) + */ +export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise { + const q = new URLSearchParams() + if (params.limit != null) q.set('limit', String(params.limit)) + if (params.status) q.set('status', params.status) + const qs = q.toString() + return api.get(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`) +} + /** * 比赛详情(含最近预测摘要) */ diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx index 6eafd29..0f1cadf 100644 --- a/frontend/src/admin/pages/Collection.tsx +++ b/frontend/src/admin/pages/Collection.tsx @@ -11,16 +11,17 @@ */ import { useEffect, useState, useCallback, useRef } from 'react' -import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal' +import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal' import type { IngestJob, League } from '../types' import type { CollectionRequest } from '../types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' +// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji const TASKS = [ - { value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' }, - { value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' }, - { value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' }, - { value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' }, + { value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' }, + { value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' }, + { value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' }, + { value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' }, ] as const type TaskUIStatus = 'idle' | 'running' | 'done' | 'error' @@ -73,6 +74,16 @@ export default function CollectionPage() { useEffect(() => () => stopPolling(), [stopPolling]) + // ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ── + // 声明须在 startJobPolling 之前(其终态回调会刷新历史) + const [recentJobs, setRecentJobs] = useState(null) + const loadRecentJobs = useCallback(async () => { + try { + setRecentJobs(await fetchIngestJobs({ limit: 10 })) + } catch { /* 历史列表失败不影响主流程 */ } + }, []) + useEffect(() => { loadRecentJobs() }, [loadRecentJobs]) + const startJobPolling = useCallback((id: string) => { stopPolling() const tick = async () => { @@ -82,12 +93,13 @@ export default function CollectionPage() { if (TERMINAL_STATUSES.has(job.status)) { setTaskStatus(job.status === 'success' ? 'done' : 'error') stopPolling() + loadRecentJobs() // 终态后刷新历史列表 } } catch { /* 单次轮询失败不影响后续 */ } } tick() pollRef.current = setInterval(tick, 3_000) - }, [stopPolling]) + }, [stopPolling, loadRecentJobs]) const isEventsTask = task === 'events' || task === 'all' @@ -183,9 +195,9 @@ export default function CollectionPage() { key={t.value} type="button" onClick={() => setTask(t.value)} - className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${ + className={`border px-3 py-2 text-left text-xs transition-colors ${ task === t.value - ? 'border-brand-500 bg-brand-50 text-brand-700' + ? 'border-press bg-press-wash text-press' : 'border-ink-200 text-ink-600 hover:border-ink-300' }`} > @@ -345,6 +357,41 @@ export default function CollectionPage() { + {/* 最近任务历史 */} + + 刷新} + /> + + {recentJobs === null ? ( +

加载中…

+ ) : recentJobs.length === 0 ? ( +

还没有任务记录,触发一次采集后这里会出现历史。

+ ) : ( +
+ {recentJobs.map(j => { + const st = j.status + const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse' + const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中' + return ( +
+
+ ) + })} +
+ )} +
+
+ {/* 数据源说明 */} diff --git a/frontend/src/admin/pages/Logs.tsx b/frontend/src/admin/pages/Logs.tsx index d8d2328..513ac30 100644 --- a/frontend/src/admin/pages/Logs.tsx +++ b/frontend/src/admin/pages/Logs.tsx @@ -85,7 +85,7 @@ export default function LogsPage() {
{error && } From db3c64d49ba1f2db93a628f68516b77039919ab7 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:46:43 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=20standings=20?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E6=96=AD=E8=A8=80=E7=9A=84=20flaky(=E5=8C=BF?= =?UTF-8?q?=E5=90=8D=E7=BA=A6=E6=9D=9F=20name=3DNone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 log-persistence 分支同款修复:constraints 集合含匿名约束(name=None), any() 子串匹配撞上 None 直接 TypeError,集合顺序不定导致间歇失败。 先判真值再匹配。 --- tests/test_p0_standings_cutoff.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_p0_standings_cutoff.py b/tests/test_p0_standings_cutoff.py index 09ad71b..3b9d397 100644 --- a/tests/test_p0_standings_cutoff.py +++ b/tests/test_p0_standings_cutoff.py @@ -73,7 +73,9 @@ class TestStandingsModel: def test_unique_constraint_includes_available_at(self): names = {c.name for c in Standing.__table__.constraints} - assert any("available" in n and n.startswith("uq_") for n in names), \ + # 匿名约束(PK/Check)的 name 是 None,必须先判真值再子串匹配, + # 否则 any() 撞上 None 直接 TypeError(集合顺序不定 → flaky) + assert any(n and "available" in n and n.startswith("uq_") for n in names), \ f"缺少含 available_at 的唯一约束,现有: {names}" def test_old_unique_constraint_removed(self):