fix: 主站页面修复 + 新增文件
- App.tsx: 路由和布局修复 - index.css: 样式修复 - Matches.tsx: 比赛列表页修复 - .zcodeignore: 新增忽略配置 - tests/ui_audit.py: UI 审计测试脚本
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 按钮在移动端显示位置不明确
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user