fix: 主站页面修复 + 新增文件

- App.tsx: 路由和布局修复
- index.css: 样式修复
- Matches.tsx: 比赛列表页修复
- .zcodeignore: 新增忽略配置
- tests/ui_audit.py: UI 审计测试脚本
This commit is contained in:
shangfangjian
2026-09-20 08:45:35 +08:00
parent 78375d6e39
commit ec8f36abb2
5 changed files with 265 additions and 176 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>
) )
} }
+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
+115 -112
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 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">
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
<div className="flex items-center justify-between sm:contents"> <div className="flex items-center justify-between sm:contents">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span> <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> <span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div> </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">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</span>
</div>
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */} {/* 客队(左对齐) */}
<span className="flex min-w-0 flex-1 items-center gap-1.5 sm:gap-2">
<TeamSideTag side="away" />
<span className="truncate text-[13px] font-medium text-ink-900 sm:text-sm">{awayName}</span>
</span>
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
<span className="hidden text-right sm:block">
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
m.match_status === 'finished'
? 'border-ink-200 text-ink-500'
: m.match_status === 'scheduled'
? 'border-ink-300 text-ink-600'
: 'border-press/30 text-press'
}`}>
{st.label}
</span>
</span>
{/* 预测按钮:小屏独占一行 */}
{!finished && ( {!finished && (
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}> <div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
<button <button
onClick={() => predict(m)} onClick={() => predict(m)}
disabled={busy} disabled={busy}
className="btn min-h-[44px] px-4" className="btn min-h-[44px] w-full px-4"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`} title="以多专家模式预测这场"
> >
{busy ? (<><Spinner /> </>) : '预测'} {busy ? (<><Spinner /> </>) : '预测'}
</button> </button>
</div> </div>
)} )}
{/* 桌面端按钮(小屏隐藏) */} {/* 预测按钮:桌面 */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span> <div className="hidden justify-end sm:flex" onClick={e => e.stopPropagation()}>
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
{!finished && ( {!finished && (
<button <button
onClick={() => predict(m)} onClick={() => predict(m)}
disabled={busy} disabled={busy}
className="btn btn-sm w-[76px]" className={`btn btn-sm w-[84px] ${busy ? '' : 'btn-solid'}`}
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`} title="以多专家模式预测这场"
> >
{busy ? (<><Spinner /> </>) : '预测'} {busy ? (<><Spinner /> </>) : '预测'}
</button> </button>
)} )}
</div> </div>
</div>{/* 关闭可点击行(clickable row) */} </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'
? 'model'
: elapsed < AGG_START ? 'agents' : 'agg' : 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,13 +702,12 @@ 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)
@@ -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,请按需使用。关闭窗口即取消'
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
</p> </p>
{mode === 'multi' && (
<p className="mt-1 text-center text-2xs text-ink-300"> <p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 , 提示:每分钟限 10 ,
</p> </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,22 +813,21 @@ 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>
) )
} }
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)
+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 按钮在移动端显示位置不明确
]