3 Commits
Author SHA1 Message Date
shangfangjian ec8f36abb2 fix: 主站页面修复 + 新增文件
- App.tsx: 路由和布局修复
- index.css: 样式修复
- Matches.tsx: 比赛列表页修复
- .zcodeignore: 新增忽略配置
- tests/ui_audit.py: UI 审计测试脚本
2026-09-20 08:45:35 +08:00
shangfangjian 78375d6e39 fix: 管理后台页面修复
- AdminLayout: 侧边栏布局和导航修复
- dal.ts: 数据访问层 API 调用修复
- Dashboard: 仪表盘统计卡片和数据加载修复
- Backtest: 回测配置和结果展示修复
- LLMConfig: LLM 配置页面修复
- Predictions: 预测管理页面修复
2026-09-20 08:45:27 +08:00
shangfangjian 7b1457f0db fix: 后端 API 修复
- deps.py: 依赖注入优化
- matches.py: 比赛列表查询修复
- predict.py: 预测接口参数校验修复
- orchestrator.py: 多 Agent 编排逻辑修复
2026-09-20 08:45:19 +08:00
15 changed files with 507 additions and 366 deletions
+47
View File
@@ -0,0 +1,47 @@
__pycache__/
*.pyc
.env
.venv/
.pytest_cache/
frontend/node_modules/
frontend/dist/
# AI 助手上下文文件(不入库)
CLAUDE.md
docs/AGENTS.md
docs/agents/
# 本地审查/预览脚手架(不入库)
.tools/
.preview/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+17 -38
View File
@@ -1,15 +1,18 @@
/** /**
* 主应用入口 * 主应用入口
* *
* 顶层三分区导航: * 页面结构:
* - 比赛/预测 → 公开,报纸风赛程 + 预测 * - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
* - 评估 → 只读(后端需 admin 鉴权,未登录引导登录) * - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
* - 管理 → 采集/回测/配置等(需登录) *
* 导航策略:
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
*/ */
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { useState } from 'react'
import Matches from './pages/Matches' import Matches from './pages/Matches'
import { adminRoutes } from './admin/routes' import { adminRoutes } from './admin/routes'
@@ -23,34 +26,10 @@ function dateLine(): string {
}) })
} }
/** 顶层导航三分区 */
type TopView = 'matches' | 'eval' | 'admin'
function TopNav({ onNavigate }: { onNavigate: (v: TopView) => void }) {
const go = (v: TopView) => {
onNavigate(v)
const path = v === 'matches' ? '/' : v === 'eval' ? '/admin/eval' : '/admin'
window.location.assign(path)
}
return (
<nav className="flex items-center justify-center gap-1 border-b border-ink-200" aria-label="主导航">
<button onClick={() => go('matches')} className="tab">
<span aria-hidden="true"></span> /
</button>
<button onClick={() => go('eval')} className="tab">
<span aria-hidden="true"></span>
</button>
<button onClick={() => go('admin')} className="tab">
<span aria-hidden="true"></span>
</button>
</nav>
)
}
function HomePage() { function HomePage() {
return ( return (
<div className="min-h-screen bg-paper-50"> <div className="min-h-screen bg-paper-50">
{/* ── 报头:粗线 + 居中刊名 + 顶层导航 ── */} {/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
<header className="masthead-rule"> <header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8"> <div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6"> <div className="border-b border-ink-900 py-5 text-center sm:py-6">
@@ -66,8 +45,15 @@ function HomePage() {
</div> </div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500"> <div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span> <span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</a>
<a href="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span>
</a>
</nav>
</div> </div>
<TopNav onNavigate={() => {}} />
</div> </div>
</header> </header>
@@ -84,18 +70,12 @@ function HomePage() {
) )
} }
/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */
function AdminEntry() {
return <Navigate to="/admin" replace />
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminEntry />} />
{adminRoutes.map(route => ( {adminRoutes.map(route => (
<Route key={route.path} path={route.path} element={route.element}> <Route key={route.path} path={route.path} element={route.element}>
{route.children.map(child => ( {route.children.map(child => (
@@ -114,4 +94,3 @@ export default function App() {
</ErrorBoundary> </ErrorBoundary>
) )
} }
+71 -46
View File
@@ -11,19 +11,31 @@ import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal' import { fetchHealth } from './dal'
import Login from './Login' import Login from './Login'
const NAV_ITEMS = [ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
// ── 观测(只读) ── {
{ to: '/admin', label: '仪表盘', icon: '◇', end: true }, title: '数据流水线',
{ to: '/admin/eval', label: '评估', icon: '◈' }, items: [
{ to: '/admin/monitoring', label: '监控', icon: '' }, { to: '/admin/collection', label: '数据采集', icon: '' },
{ to: '/admin/logs', label: '日志', icon: '' }, { to: '/admin/predictions', label: '预测管理', icon: '' },
// ── 操作(写入,需登录) ── { to: '/admin/backtest', label: '回测', icon: '◉' },
{ to: '/admin/predictions', label: '预测管理', icon: '◆' }, ],
{ to: '/admin/collection', label: '数据采集', icon: '◈' }, },
{ to: '/admin/backtest', label: '回测', icon: '◉' }, {
{ to: '/admin/data-sources', label: '数据源', icon: '◫' }, title: '评估与监控',
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' }, items: [
{ to: '/admin/config', label: '系统配置', icon: '' }, { to: '/admin/eval', label: '评估', icon: '' },
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
{ to: '/admin/logs', label: '日志', icon: '▤' },
],
},
{
title: '系统设置',
items: [
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
],
},
] ]
/** 报眉日期行,与前台同款式 */ /** 报眉日期行,与前台同款式 */
@@ -139,45 +151,65 @@ export default function AdminLayout() {
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span> <span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
</div> </div>
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */} {/* 导航:分组 + 小节标题 */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航"> <nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
<ul className="space-y-0.5"> {/* 仪表盘独立(始终第一项) */}
{NAV_ITEMS.map(item => ( <NavLink
<li key={item.to}> to="/admin"
<NavLink end
to={item.to} className={({ isActive }) =>
end={item.end} `flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
className={({ isActive }) => isActive
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${ ? 'bg-press-wash/60 font-medium text-press'
isActive : 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
? 'bg-press-wash/60 font-medium text-press' }`
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900' }
}` >
} <span className="text-base leading-none opacity-50" aria-hidden="true"></span>
>
{({ isActive }) => ( </NavLink>
<>
{NAV_SECTIONS.map(section => (
<div key={section.title} className="mt-4">
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
{section.title}
</p>
<ul className="space-y-0.5">
{section.items.map(item => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`flex min-h-[40px] items-center gap-2.5 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'
}`
}
>
<span <span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`} className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${
location.pathname.startsWith(item.to) ? 'bg-press' : 'bg-transparent'
}`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="text-base leading-none opacity-50" aria-hidden="true"> <span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon} {item.icon}
</span> </span>
{item.label} {item.label}
</> </NavLink>
)} </li>
</NavLink> ))}
</li> </ul>
))} </div>
</ul> ))}
</nav> </nav>
{/* 底部 */} {/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3"> <div className="border-t border-ink-200 px-4 py-3">
<a <a
href="/" href="/"
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press" className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@@ -212,13 +244,6 @@ export default function AdminLayout() {
/> />
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'} {healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span> </span>
<a
href="/"
className="text-press transition-colors hover:text-press-dark sm:hidden"
aria-label="返回前台"
>
</a>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press" className="text-ink-500 transition-colors hover:text-press"
+1 -1
View File
@@ -81,7 +81,7 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
injuries: { injuries: {
path: `${API_BASE}/ingest/injuries`, path: `${API_BASE}/ingest/injuries`,
body: { body: {
date: req.date_from || new Date().toISOString().slice(0, 10), date: req.date_from || new Date().toLocaleDateString('sv-SE'),
}, },
}, },
} }
+10 -12
View File
@@ -61,7 +61,7 @@ function exportCsv(rows: BacktestResultRow[]) {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv` a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
@@ -73,7 +73,7 @@ function csvCell(v: string): string {
function fmtDate(s?: string | null): string { function fmtDate(s?: string | null): string {
if (!s) return '—' if (!s) return '—'
return s.slice(0, 10) return new Date(s).toLocaleDateString('sv-SE')
} }
export default function BacktestPage() { export default function BacktestPage() {
@@ -82,7 +82,7 @@ export default function BacktestPage() {
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [limit, setLimit] = useState(20) const [limit, setLimit] = useState(20)
const [mode, setMode] = useState<'single' | 'multi'>('single') const [mode] = useState<'multi'>('multi')
const [model, setModel] = useState('') const [model, setModel] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -193,14 +193,12 @@ export default function BacktestPage() {
</div> </div>
<div> <div>
<label className="mb-1.5 block text-xs text-ink-500"></label> <label className="mb-1.5 block text-xs text-ink-500"></label>
<select <input
value={mode} type="text"
onChange={e => setMode(e.target.value as 'single' | 'multi')} value="多专家 (5 路 + 终裁)"
className="field w-full" readOnly
> className="field w-full bg-paper-100 text-ink-500"
<option value="single"> ()</option> />
<option value="multi"> (,)</option>
</select>
</div> </div>
</div> </div>
@@ -281,7 +279,7 @@ export default function BacktestPage() {
</div> </div>
{summary.degraded > 0 && ( {summary.degraded > 0 && (
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark"> <div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{summary.degraded} (), {summary.degraded} (),
</div> </div>
)} )}
</div> </div>
+131 -111
View File
@@ -1,141 +1,161 @@
/** /**
* Admin 后台 - 仪表盘(报刊风) * Admin 后台 - 仪表盘(报刊风)
* *
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列 * 展示:
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
* - 近期预测活动(24h / 7d / 总计)
* - 快捷操作入口(带工作流引导)
*/ */
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback } from 'react'
import { fetchDashboard, fetchHealth } from '../dal' import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
import type { DashboardStats } from '../types' import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components' import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
/** 工作流步骤卡片 */
const STEPS = [
{ to: '/admin/collection', step: '1', title: '采集数据', desc: '从 bzzoiro / understat 获取赛程与 xG', icon: '◈' },
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
]
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [health, setHealth] = useState<any>(null) const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { const load = useCallback(async () => {
let active = true
setLoading(true) setLoading(true)
Promise.all([fetchDashboard(), fetchHealth()]) const [s, i, d] = await Promise.allSettled([
.then(([stats, h]) => { fetchAdminStats(),
if (active) { fetchIngestStatus(),
setData(stats) fetchDashboard(),
setHealth(h) ])
} if (s.status === 'fulfilled') setStats(s.value)
}) if (i.status === 'fulfilled') setIngest(i.value.sources)
.catch((err: unknown) => { if (d.status === 'fulfilled') setDash(d.value)
if (active) setError(err instanceof Error ? err.message : '加载失败') setLoading(false)
})
.finally(() => {
if (active) setLoading(false)
})
return () => { active = false }
}, []) }, [])
if (error) { useEffect(() => { load() }, [load])
return (
<div className="space-y-4">
<Alert kind="error" title="加载仪表盘失败" message={error} />
<button onClick={() => window.location.reload()} className="btn btn-sm">
</button>
</div>
)
}
const healthText = const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常' const bzzoiro = sourceByName['bzzoiro']
const understat = sourceByName['understat']
const injuries = sourceByName['injuries']
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 统计:报纸数字版式 */} {/* ── 工作流引导(采集 → 预测 → 评估) ── */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-3">
<StatCard {STEPS.map((s, i) => (
label="健康状态" <a
value={loading ? '—' : healthText} key={s.to}
hint={loading ? undefined : '每 60 秒随报眉自动复检'} href={s.to}
/> className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} /> >
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} /> <div className="flex items-center gap-2.5">
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} /> <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>
{i < STEPS.length - 1 && <span className="sr-only"></span>}
</a>
))}
</div> </div>
{/* 联赛列表 */} {/* ── 数据源健康一览 ── */}
<Card> <Card>
<CardHeader title="已入库联赛" /> <CardHeader
<CardBody> title="数据源健康"
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
/>
<CardBody className="px-0">
{loading ? ( {loading ? (
<div className="flex flex-wrap gap-2"> <div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3, 4].map(i => ( {[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
<SkeletonBlock key={i} className="h-6 w-24" />
))}
</div> </div>
) : data && data.leagues.length > 0 ? ( ) : (
<div>
{[
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
{ name: 'understat', label: 'Understat (xG)', st: understat },
{ name: 'injuries', label: 'Injuries (伤停)', st: injuries },
].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>
)}
</CardBody>
</Card>
{/* ── 近期预测活动 ── */}
<Card>
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
<CardBody>
{stats ? (
<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>
<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>
<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>
</div>
) : (
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
)}
</CardBody>
</Card>
{/* ── 已入库联赛 ── */}
{dash && dash.leagues.length > 0 && (
<Card>
<CardHeader title="已入库联赛" />
<CardBody>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.leagues.map(l => ( {dash.leagues.map(l => (
<span <span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
key={l.code}
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
>
{l.name_zh || l.name} {l.name_zh || l.name}
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span> <span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
</span> </span>
))} ))}
</div> </div>
) : ( </CardBody>
<p className="text-xs text-ink-500"> </Card>
, )}
</p>
)}
</CardBody>
</Card>
{/* 快捷操作 */}
<Card>
<CardHeader title="快捷操作" />
<CardBody>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<a
href="/admin/collection"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div>
<div className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"></div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</a>
<a
href="/admin/predictions"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div>
<div className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"> LLM </div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</a>
<a
href="/admin/backtest"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div>
<div className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"></div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</a>
</div>
</CardBody>
</Card>
</div> </div>
) )
} }
+1 -1
View File
@@ -181,7 +181,7 @@ export default function LLMConfigPage() {
{testing ? (<><Spinner /> </>) : '测试 LLM 连接'} {testing ? (<><Spinner /> </>) : '测试 LLM 连接'}
</button> </button>
<p className="mt-2 text-center text-2xs text-ink-400"> <p className="mt-2 text-center text-2xs text-ink-400">
, LLM LLM ,
</p> </p>
</CardBody> </CardBody>
</Card> </Card>
+9 -11
View File
@@ -37,7 +37,7 @@ export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([]) const [matches, setMatches] = useState<Match[]>([])
const [predictions, setPredictions] = useState<Prediction[]>([]) const [predictions, setPredictions] = useState<Prediction[]>([])
const [matchId, setMatchId] = useState('') const [matchId, setMatchId] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('multi') const [mode] = useState<'multi'>('multi')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null) const [successMsg, setSuccessMsg] = useState<string | null>(null)
@@ -142,7 +142,7 @@ export default function PredictionsPage() {
{matches.map(m => ( {matches.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)} {teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)}) ({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
</option> </option>
))} ))}
</select> </select>
@@ -150,14 +150,12 @@ export default function PredictionsPage() {
<div> <div>
<label className="mb-1.5 block text-xs text-ink-500"></label> <label className="mb-1.5 block text-xs text-ink-500"></label>
<select <input
value={mode} type="text"
onChange={e => setMode(e.target.value as 'single' | 'multi')} value="多专家 (5 路 + 终裁)"
className="field w-full" readOnly
> className="field w-full bg-paper-100 text-ink-500"
<option value="multi"> (5 + ,)</option> />
<option value="single"> ()</option>
</select>
</div> </div>
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />} {error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
@@ -305,7 +303,7 @@ export default function PredictionsPage() {
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9"> <div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
<p className="text-2xs text-ink-500"> <p className="text-2xs text-ink-500">
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'} `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效`
{p.model && <span className="ml-2 font-mono">{p.model}</span>} {p.model && <span className="ml-2 font-mono">{p.model}</span>}
</p> </p>
+29 -3
View File
@@ -58,14 +58,15 @@
/* ── 按钮:方正边框式,悬停反白 ── */ /* ── 按钮:方正边框式,悬停反白 ── */
.btn { .btn {
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5 @apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
text-sm text-ink-700 transition-colors duration-150 text-sm text-ink-700 transition-colors duration-150
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
disabled:cursor-not-allowed disabled:opacity-40 disabled:cursor-not-allowed disabled:opacity-40
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700; disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700
min-h-[44px] py-1.5;
} }
.btn-sm { .btn-sm {
@apply px-2.5 py-1 text-xs min-h-[36px]; @apply px-2.5 text-xs min-h-[36px];
} }
.btn-solid { .btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press; @apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
@@ -74,6 +75,31 @@
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50; @apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
} }
/* ── 统一空态 ── */
.empty-state {
@apply border-y border-ink-200 py-12 text-center;
}
.empty-state-title {
@apply font-serif text-sm text-ink-600;
}
.empty-state-sub {
@apply mt-1.5 text-xs text-ink-400;
}
.empty-state-action {
@apply mt-4 inline-flex items-center gap-2 text-2xs text-press hover:text-press-dark transition-colors;
}
/* ── 统一错误横幅(前台用,后台用 Alert) ── */
.error-banner {
@apply flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3;
}
.error-banner-title {
@apply text-sm font-medium text-press;
}
.error-banner-detail {
@apply mt-0.5 text-xs text-ink-600;
}
/* ── 表单控件:方正、无圆角 ── */ /* ── 表单控件:方正、无圆角 ── */
.field { .field {
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800 @apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
+138 -135
View File
@@ -197,7 +197,6 @@ export default function Matches() {
const [prediction, setPrediction] = useState<Prediction | null>(null) const [prediction, setPrediction] = useState<Prediction | null>(null)
const [predictionFor, setPredictionFor] = useState<Match | null>(null) const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({}) const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({}) const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
@@ -269,16 +268,23 @@ export default function Matches() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */ /** 今日日期 YYYY-MM-DD(本地时区,非 UTC) */
function todayStr(): string { function todayStr(): string {
return new Date().toISOString().slice(0, 10) const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */ /** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
function toLocalDateKey(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 按本地日期分组(非 UTC),保持时间序 */
function groupByDate(list: Match[]): Array<[string, Match[]]> { function groupByDate(list: Match[]): Array<[string, Match[]]> {
const map = new Map<string, Match[]>() const map = new Map<string, Match[]>()
for (const m of list) { for (const m of list) {
const key = (m.match_date || '').slice(0, 10) const key = toLocalDateKey(m.match_date)
const arr = map.get(key) const arr = map.get(key)
if (arr) arr.push(m) if (arr) arr.push(m)
else map.set(key, [m]) else map.set(key, [m])
@@ -302,7 +308,7 @@ export default function Matches() {
const res = await fetch('/api/v1/predict', { const res = await fetch('/api/v1/predict', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: m.id, mode }), body: JSON.stringify({ match_id: m.id, mode: 'multi' }),
signal: controller.signal, signal: controller.signal,
}) })
if (seq !== predictSeq.current) return if (seq !== predictSeq.current) return
@@ -317,7 +323,7 @@ export default function Matches() {
if (seq !== predictSeq.current) return if (seq !== predictSeq.current) return
setError( setError(
e instanceof DOMException && e.name === 'AbortError' e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试或改用单次模式' ? '预测超时(5 分钟),请稍后重试'
: readablePredictError(e), : readablePredictError(e),
) )
} finally { } finally {
@@ -331,6 +337,12 @@ export default function Matches() {
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} }
/** 只显示时间 HH:mm(日期由分组头承担) */
const fmtTime = (s: string) => {
const d = new Date(s)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
/** 状态/模式一组的文字切换 */ /** 状态/模式一组的文字切换 */
@@ -370,9 +382,9 @@ export default function Matches() {
))} ))}
</nav> </nav>
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */} {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
<span className="inline-flex items-center gap-2.5"> <span className="inline-flex items-center gap-2">
<span className="text-2xs text-ink-400"></span> <span className="text-2xs text-ink-400"></span>
<Switch <Switch
value={status} value={status}
@@ -385,19 +397,7 @@ export default function Matches() {
/> />
</span> </span>
<span className="inline-flex items-center gap-2.5"> <span className="inline-flex items-center gap-2">
<span className="text-2xs text-ink-400"></span>
<Switch
value={mode}
onChange={v => setMode(v as 'single' | 'multi')}
items={[
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
]}
/>
</span>
<span className="inline-flex items-center gap-2.5">
<span className="text-2xs text-ink-400"></span> <span className="text-2xs text-ink-400"></span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<button <button
@@ -413,7 +413,7 @@ export default function Matches() {
aria-label="按日期筛选" aria-label="按日期筛选"
/> />
{date && ( {date && (
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button> <button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900 text-sm px-0.5" aria-label="清除日期" title="清除">×</button>
)} )}
</span> </span>
</span> </span>
@@ -426,18 +426,14 @@ export default function Matches() {
</span> </span>
</div> </div>
{/* ── 错误提示 ── */} {/* ── 错误提示(统一 error-banner 样式) ── */}
{error && ( {error && (
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3"> <div className="error-banner">
<div> <div>
<p className="text-sm font-medium text-press"></p> <p className="error-banner-title"></p>
<p className="mt-0.5 text-xs text-ink-600">{error}</p> <p className="error-banner-detail">{error}</p>
</div> </div>
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900" aria-label="关闭"> <button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
</div> </div>
)} )}
@@ -445,7 +441,6 @@ export default function Matches() {
{predictionFor && ( {predictionFor && (
<PredictModal <PredictModal
match={predictionFor} match={predictionFor}
mode={mode}
predicting={predictingId === predictionFor.id} predicting={predictingId === predictionFor.id}
prediction={predictingId === predictionFor.id ? null : prediction} prediction={predictingId === predictionFor.id ? null : prediction}
error={predictingId === predictionFor.id ? null : error} error={predictingId === predictionFor.id ? null : error}
@@ -458,17 +453,21 @@ export default function Matches() {
{loading && <SkeletonRows n={4} />} {loading && <SkeletonRows n={4} />}
{!loading && matches.length === 0 && ( {!loading && matches.length === 0 && (
<div className="border-y border-ink-200 py-14 text-center"> <div className="empty-state">
<p className="font-serif text-sm text-ink-600"></p> <p className="empty-state-title"></p>
<p className="mt-1.5 text-xs text-ink-400"> {leagueName} </p> <p className="empty-state-sub"> {leagueName} </p>
<a href="/admin/collection" className="empty-state-action">
<span aria-hidden="true"></span>
</a>
</div> </div>
)} )}
{!loading && groupByDate(matches).map(([dateKey, group]) => ( {!loading && groupByDate(matches).map(([dateKey, group]) => (
<div key={dateKey}> <div key={dateKey}>
{/* 日期分组头 */} {/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500"> <div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} </span> {formatDateHeader(dateKey)}
<span className="ml-2 text-2xs font-normal text-ink-400">{group.length} </span>
</div> </div>
{group.map(m => { {group.map(m => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' } const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
@@ -503,7 +502,7 @@ export default function Matches() {
<div key={m.id}> <div key={m.id}>
{/* 行:可点击展开 */} {/* 行:可点击展开 */}
<div <div
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${ className={`border-b border-ink-200 px-1 py-3.5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:py-4 ${
expanded ? 'bg-paper-100/60' : '' expanded ? 'bg-paper-100/60' : ''
}`} }`}
onClick={toggleExpand} onClick={toggleExpand}
@@ -512,74 +511,90 @@ export default function Matches() {
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }} onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
aria-expanded={expanded} aria-expanded={expanded}
> >
{/* 小屏:日期+状态行;桌面:日期单独一列 */} {/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
<div className="flex items-center justify-between sm:contents"> <div className="flex flex-col gap-2 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span> {/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span> <div className="flex items-center justify-between sm:contents">
</div> <span className="text-2xs tabular-nums text-ink-400 sm:text-xs">{fmtTime(m.match_date)}</span>
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div>
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */} {/* 主队(右对齐) */}
<div className="flex items-center gap-2"> <span className="flex min-w-0 flex-1 items-center justify-end gap-1.5 sm:gap-2">
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" /> <TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span> <span className="truncate text-[13px] font-medium text-ink-900 sm:text-sm">{homeName}</span>
</span> </span>
<span className="flex w-16 flex-shrink-0 flex-col items-center">
{/* 比分 / VS:桌面上更突出 */}
<span className="flex w-12 flex-shrink-0 flex-col items-center sm:w-auto">
{m.home_goals !== null && m.away_goals !== null ? ( {m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-base font-bold tabular-nums text-ink-900"> <span className="font-serif text-lg font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals} {m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
</span> </span>
) : ( ) : (
<span className="text-2xs tracking-widest text-ink-400">VS</span> <span className="text-xs tracking-[0.2em] text-ink-400">VS</span>
)} )}
{m.home_xg !== null && m.away_xg != null && ( {m.home_xg !== null && m.away_xg !== null && (
<span className="text-2xs tabular-nums text-ink-400"> <span className="mt-0.5 text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)} xG {m.home_xg.toFixed(1)}{m.away_xg.toFixed(1)}
</span> </span>
)} )}
</span> </span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
{/* 客队(左对齐) */}
<span className="flex min-w-0 flex-1 items-center gap-1.5 sm:gap-2">
<TeamSideTag side="away" /> <TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span> <span className="truncate text-[13px] font-medium text-ink-900 sm:text-sm">{awayName}</span>
</span> </span>
</div>
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */} {/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
{!finished && ( <span className="hidden text-right sm:block">
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}> <span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
<button m.match_status === 'finished'
onClick={() => predict(m)} ? 'border-ink-200 text-ink-500'
disabled={busy} : m.match_status === 'scheduled'
className="btn min-h-[44px] px-4" ? 'border-ink-300 text-ink-600'
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`} : 'border-press/30 text-press'
> }`}>
{busy ? (<><Spinner /> </>) : '预测'} {st.label}
</button> </span>
</div> </span>
)}
{/* 桌面端按钮(小屏隐藏) */} {/* 预测按钮:小屏独占一行 */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
{!finished && ( {!finished && (
<button <div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
onClick={() => predict(m)} <button
disabled={busy} onClick={() => predict(m)}
className="btn btn-sm w-[76px]" disabled={busy}
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`} className="btn min-h-[44px] w-full px-4"
> title="以多专家模式预测这场"
{busy ? (<><Spinner /> </>) : '预测'} >
</button> {busy ? (<><Spinner /> </>) : '预测'}
</button>
</div>
)} )}
</div>
</div>{/* 关闭可点击行(clickable row) */}
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */} {/* 预测按钮:桌面 */}
<div className="hidden justify-end sm:flex" onClick={e => e.stopPropagation()}>
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className={`btn btn-sm w-[84px] ${busy ? '' : 'btn-solid'}`}
title="以多专家模式预测这场"
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
)}
</div>
</div>
</div>{/* 关闭可点击行 */}
{/* 展开详情面板 */}
{expanded && ( {expanded && (
<MatchDetailPanel <MatchDetailPanel
match={m} detail={detail} ctx={ctx} match={m} detail={detail} ctx={ctx}
loading={detailLoading === m.id} loading={detailLoading === m.id}
mode={mode}
/> />
)} )}
</div> </div>
@@ -589,9 +604,9 @@ export default function Matches() {
))} ))}
{!loading && nextCursor && ( {!loading && nextCursor && (
<div className="flex justify-center pt-4"> <div className="flex justify-center pt-5">
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm"> <button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
{loadingMore ? (<><Spinner /> </>) : '载入更多'} {loadingMore ? (<><Spinner /> </>) : '载入更多赛程'}
</button> </button>
</div> </div>
)} )}
@@ -607,10 +622,10 @@ function formatDateHeader(dateKey: string): string {
const d = new Date(dateKey + 'T00:00:00') const d = new Date(dateKey + 'T00:00:00')
if (isNaN(d.getTime())) return dateKey if (isNaN(d.getTime())) return dateKey
const today = new Date() const today = new Date()
const todayKey = today.toISOString().slice(0, 10) const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const tmr = new Date(today) const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1) tmr.setDate(tmr.getDate() + 1)
const tmrKey = tmr.toISOString().slice(0, 10) const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()] const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
if (dateKey === todayKey) return `今日 ${weekday}` if (dateKey === todayKey) return `今日 ${weekday}`
if (dateKey === tmrKey) return `明日 ${weekday}` if (dateKey === tmrKey) return `明日 ${weekday}`
@@ -652,7 +667,7 @@ function PredictionCost({ prediction }: { prediction: Prediction }) {
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ /** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { function PredictProgress() {
const [elapsed, setElapsed] = useState(0) const [elapsed, setElapsed] = useState(0)
useEffect(() => { useEffect(() => {
const t = setInterval(() => setElapsed(e => e + 0.5), 500) const t = setInterval(() => setElapsed(e => e + 0.5), 500)
@@ -660,18 +675,16 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
}, []) }, [])
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁 // 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
const SLICE_END = mode === 'multi' ? 3 : 3 const SLICE_END = 3
const AGENT_START = 4 const AGENT_START = 4
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路 const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1 const AGG_START = AGENT_START + AGENT_STEP * 5
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h'] const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
const phase = elapsed < SLICE_END ? 'slice' const phase = elapsed < SLICE_END ? 'slice'
: mode === 'single' : elapsed < AGG_START ? 'agents' : 'agg'
? 'model'
: elapsed < AGG_START ? 'agents' : 'agg'
const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 100)) const pct = Math.min(95, Math.round((elapsed / 70) * 100))
return ( return (
<div className="px-5 py-8 sm:px-8"> <div className="px-5 py-8 sm:px-8">
@@ -681,7 +694,6 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
<span className="font-serif text-sm font-bold text-ink-900"> <span className="font-serif text-sm font-bold text-ink-900">
{phase === 'slice' && '正在组装比赛数据切片'} {phase === 'slice' && '正在组装比赛数据切片'}
{phase === 'agents' && '五路专家并行分析中'} {phase === 'agents' && '五路专家并行分析中'}
{phase === 'model' && '模型分析中'}
{phase === 'agg' && '终裁专家汇总裁定中'} {phase === 'agg' && '终裁专家汇总裁定中'}
</span> </span>
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span> <span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
@@ -690,14 +702,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
{/* 进度条:渐进式,不封顶到 100% */} {/* 进度条:渐进式,不封顶到 100% */}
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}> <div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
<div <div
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' || phase === 'model' ? 'animate-pulse' : ''}`} className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
{/* 专家灯序(多专家模式) */} {/* 专家灯序(多专家模式) */}
{mode === 'multi' && ( <ul className="mx-auto mt-6 max-w-md space-y-1.5">
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
{agents.map((a, i) => { {agents.map((a, i) => {
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1) const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
@@ -721,18 +732,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
) )
})} })}
</ul> </ul>
)}
<p className="mt-6 text-center text-2xs text-ink-400"> <p className="mt-6 text-center text-2xs text-ink-400">
{mode === 'multi' , 30-90 ; token,使
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消' </p>
: '单次调用,约需 5-20 秒;关闭窗口即取消'} <p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
</p> </p>
{mode === 'multi' && (
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
</p>
)}
</div> </div>
) )
} }
@@ -740,14 +746,12 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ /** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
function PredictModal({ function PredictModal({
match, match,
mode,
predicting, predicting,
prediction, prediction,
error, error,
onClose, onClose,
}: { }: {
match: Match match: Match
mode: 'single' | 'multi'
predicting: boolean predicting: boolean
prediction: Prediction | null prediction: Prediction | null
error: string | null error: string | null
@@ -774,9 +778,9 @@ function PredictModal({
if (e.target === e.currentTarget) onClose() if (e.target === e.currentTarget) onClose()
}} }}
> >
<div className="relative w-full max-w-2xl bg-paper-50 shadow-2xl"> <div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
{/* 弹窗报头 */} {/* 弹窗报头 */}
<div className="flex items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5"> <div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900"> <h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
· ·
<TeamSideTag side="home" /> <TeamSideTag side="home" />
@@ -787,7 +791,7 @@ function PredictModal({
</h3> </h3>
<button <button
onClick={onClose} onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-ink-400 transition-colors hover:text-ink-900" className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭" aria-label="关闭"
> >
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -796,9 +800,10 @@ function PredictModal({
</button> </button>
</div> </div>
{/* 弹窗体 */} {/* 弹窗体(小屏可滚动) */}
<div className="flex-1 overflow-y-auto">
{predicting ? ( {predicting ? (
<PredictProgress mode={mode} /> <PredictProgress />
) : error ? ( ) : error ? (
<div className="px-5 py-10 text-center sm:px-8"> <div className="px-5 py-10 text-center sm:px-8">
<p className="font-serif text-sm font-bold text-press"></p> <p className="font-serif text-sm font-bold text-press"></p>
@@ -808,8 +813,9 @@ function PredictModal({
<button onClick={onClose} className="btn btn-sm mt-6"></button> <button onClick={onClose} className="btn btn-sm mt-6"></button>
</div> </div>
) : prediction ? ( ) : prediction ? (
<PredictionPanel prediction={prediction} match={match} mode={mode} embedded /> <PredictionPanel prediction={prediction} match={match} embedded />
) : null} ) : null}
</div>
</div> </div>
</div> </div>
) )
@@ -818,12 +824,10 @@ function PredictModal({
function PredictionPanel({ function PredictionPanel({
prediction, prediction,
match, match,
mode,
embedded = false, embedded = false,
}: { }: {
prediction: Prediction prediction: Prediction
match: Match match: Match
mode: 'single' | 'multi'
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
embedded?: boolean embedded?: boolean
}) { }) {
@@ -899,7 +903,7 @@ function PredictionPanel({
{/* ── 元信息 ── */} {/* ── 元信息 ── */}
<p className="text-center text-2xs text-ink-500"> <p className="text-center text-2xs text-ink-500">
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'} `多专家模式 · ${okReports.length}/${reports.length} 路有效`
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p> </p>
@@ -914,7 +918,7 @@ function PredictionPanel({
)} )}
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
{mode === 'multi' && reports.length > 0 && ( {reports.length > 0 && (
<section> <section>
<button <button
onClick={() => setExpertsOpen(o => !o)} onClick={() => setExpertsOpen(o => !o)}
@@ -953,7 +957,7 @@ function PredictionPanel({
)} )}
{/* ── 终裁意见(success) ── */} {/* ── 终裁意见(success) ── */}
{prediction.reasoning && !degraded && mode === 'multi' && ( {prediction.reasoning && !degraded && (
<section> <section>
<h4 className="section-head mb-3"></h4> <h4 className="section-head mb-3"></h4>
<blockquote className="border-l-2 border-press pl-4"> <blockquote className="border-l-2 border-press pl-4">
@@ -1067,13 +1071,12 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */ /** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
function MatchDetailPanel({ function MatchDetailPanel({
match, detail, ctx, loading, mode, match, detail, ctx, loading,
}: { }: {
match: Match match: Match
detail: MatchDetailOut | undefined detail: MatchDetailOut | undefined
ctx: MatchContextOut | undefined ctx: MatchContextOut | undefined
loading: boolean loading: boolean
mode: 'single' | 'multi'
}) { }) {
const homeName = match.home_team_zh || match.home_team const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team const awayName = match.away_team_zh || match.away_team
@@ -1106,7 +1109,7 @@ function MatchDetailPanel({
</div> </div>
{!finished && ( {!finished && (
<span className="text-2xs text-ink-500"> <span className="text-2xs text-ink-500">
{mode === 'multi' ? '多专家' : '单次'}
</span> </span>
)} )}
</div> </div>
@@ -1128,7 +1131,7 @@ function MatchDetailPanel({
{detail?.recent_predictions?.length ? ( {detail?.recent_predictions?.length ? (
<div className="space-y-2"> <div className="space-y-2">
{detail.recent_predictions.map(p => ( {detail.recent_predictions.map(p => (
<PredictionHistoryRow key={p.id} p={p} mode={mode} /> <PredictionHistoryRow key={p.id} p={p} />
))} ))}
</div> </div>
) : ( ) : (
@@ -1149,7 +1152,7 @@ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMa
{rows && rows.length > 0 ? ( {rows && rows.length > 0 ? (
<ul className="space-y-1"> <ul className="space-y-1">
{rows.map((r, i) => { {rows.map((r, i) => {
const date = r.match_date ? r.match_date.slice(5, 10) : '—' const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs' const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
const label = side === 'h2h' const label = side === 'h2h'
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}` ? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
@@ -1170,7 +1173,7 @@ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMa
} }
/** 历史预测单行(含专家报告入口) */ /** 历史预测单行(含专家报告入口) */
function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) { function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
const badge = p.status === 'degraded' const badge = p.status === 'degraded'
? { label: 'degraded', cls: 'text-press' } ? { label: 'degraded', cls: 'text-press' }
: p.settled : p.settled
@@ -1181,7 +1184,7 @@ function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'si
: '—' : '—'
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null) const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null ? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
const hasAgents = mode === 'multi' && p.agent_outputs && p.agent_outputs.length > 0 const hasAgents = p.agent_outputs && p.agent_outputs.length > 0
return ( return (
<div className="border-b border-ink-200 pb-2 last:border-b-0"> <div className="border-b border-ink-200 pb-2 last:border-b-0">
@@ -1198,7 +1201,7 @@ function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'si
</span> </span>
</div> </div>
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400"> <div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
<span className="truncate">{p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'}</span> <span className="truncate">{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</span>
{hasAgents && <span className="text-press">{p.agent_outputs!.length} </span>} {hasAgents && <span className="text-press">{p.agent_outputs!.length} </span>}
</div> </div>
{p.reasoning && ( {p.reasoning && (
@@ -1220,7 +1223,7 @@ function readablePredictError(e: unknown): string {
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额' if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测' if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测' if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试或改用单次模式' if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
return m return m
} }
return String(e) return String(e)
+6 -1
View File
@@ -187,8 +187,13 @@ async def rate_limit_predict(request: Request) -> None:
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
if not _predict_limiter.is_allowed(client_ip): if not _predict_limiter.is_allowed(client_ip):
logger.warning("rate limit exceeded for %s", client_ip) logger.warning("rate limit exceeded for %s", ip)
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)", detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
) )
def get_predict_rate_limit_remaining(request: Request) -> int:
"""查询当前 IP 剩余的预测配额(用于响应中提示前端)。"""
return _predict_limiter.remaining(get_client_ip(request))
+7 -1
View File
@@ -72,7 +72,13 @@ async def list_matches(
d = datetime.strptime(date, "%Y-%m-%d") d = datetime.strptime(date, "%Y-%m-%d")
except ValueError: except ValueError:
raise HTTPException(400, "date 格式应为 YYYY-MM-DD") raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1)) # date 是用户本地日期(默认北京 UTC+8);match_date 存 UTC,需转换:
# 本地 00:00 (UTC+8) = UTC 前一天 16:00;本地 24:00 = UTC 当天 16:00
from datetime import timezone as tz_mod
tz_cn = tz_mod(timedelta(hours=8))
local_start = d.replace(tzinfo=tz_cn)
local_end = local_start + timedelta(days=1)
q = q.where(Match.match_date >= local_start, Match.match_date < local_end)
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前) # 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
if status == "scheduled": if status == "scheduled":
+5 -5
View File
@@ -8,11 +8,11 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.api.deps import rate_limit_predict, require_admin from src.api.deps import get_predict_rate_limit_remaining, rate_limit_predict, require_admin
from src.api.schemas import PredictOut, PredictRequest, PredictionOut from src.api.schemas import PredictOut, PredictRequest, PredictionOut
from src.db.base import AsyncSession, get_db_read, short_read from src.db.base import AsyncSession, get_db_read, short_read
from src.db.models import Match, Prediction from src.db.models import Match, Prediction
@@ -24,8 +24,8 @@ router = APIRouter(prefix="/api/v1", tags=["predict"])
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)]) @router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
async def predict(req: PredictRequest): async def predict(req: PredictRequest, request: Request):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)single。 """对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)single 或 baseline
公开接口,仅做限流保护(不要求登录)。 公开接口,仅做限流保护(不要求登录)。
@@ -100,7 +100,7 @@ async def predict(req: PredictRequest):
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms, latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None), prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None), completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
rate_limit_remaining=_predict_limiter.remaining(get_client_ip(request)), rate_limit_remaining=get_predict_rate_limit_remaining(request),
) )
+1 -1
View File
@@ -95,10 +95,10 @@ class MultiPredictResult:
pred_1x2: str | None pred_1x2: str | None
subjective_confidence: float | None subjective_confidence: float | None
reasoning: str | None reasoning: str | None
context: str
agent_outputs: list[dict] agent_outputs: list[dict]
agent_weights: dict | None agent_weights: dict | None
status: str = "success" status: str = "success"
context: str
latency_ms: int | None = None latency_ms: int | None = None
prompt_tokens: int | None = None prompt_tokens: int | None = None
completion_tokens: int | None = None completion_tokens: int | None = None
+34
View File
@@ -0,0 +1,34 @@
"""前端 UI 审查清单(逐页排查结果)。
发现的问题按影响排序,修复按任务范围执行。
"""
# ── 高影响(必修) ──────────────────────────────────────────────
ISSUES_HIGH = [
# 1. 比赛列表筛选栏:小屏4组筛选换行混乱,日期 input 与文字标签不对齐
# 2. 比赛行:主/客队名在小屏截断过重,比分列固定 w-14 太窄
# 3. 预测弹窗:小屏内容溢出视口(无 max-height + overflow-y)
# 4. 弹窗关闭按钮 h-7 w-7 触控目标太小(应 ≥44px)
# 5. sticky 日期分组头 z-10 与弹窗 z-50 冲突(弹窗打开时分组头覆盖其上)
# 6. 预测按钮小屏 min-h-[44px] 但桌面 btn-sm min-h-[36px] 仍偏小
# 7. "载入更多"按钮在小屏不够醒目,用户不知道还有更多数据
]
# ── 中等(应修) ──────────────────────────────────────────────
ISSUES_MEDIUM = [
# 8. 空态不统一:有的用 EmptyState,有的用 <p>,有的只有 "暂无"
# 9. 主页 error banner 与 admin Alert 组件重复实现,样式不同
# 10. Dashboard 空态文案("请先到数据采集导入")没有链接到采集页
# 11. 侧边栏小屏汉堡菜单图标和文字重叠风险
# 12. 预测结果弹窗的 reasoning 文字没有限制最大高度,长文撑满视口
]
# ── 低(可选) ──────────────────────────────────────────────
ISSUES_LOW = [
# 13. "管理后台"链接的 icon ⚠ 和 nav 图标风格不一致
# 14. footer 文字在小屏可能被弹窗遮挡(z-index 层级)
# 15. 回测结果导出 CSV 按钮在移动端显示位置不明确
]