From 1cdc6411f514524f757b88c0026e21f976a87fd1 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 12:33:15 +0800 Subject: [PATCH] =?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 ?? '-'} -

- )} -
-
- ) - })} -
- )} -
-
-
- ) -}