Compare commits
3
Commits
8e6ad5394e
...
ec8f36abb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8f36abb2 | ||
|
|
78375d6e39 | ||
|
|
7b1457f0db |
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '数据流水线',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||||
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
|
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '评估与监控',
|
||||||
|
items: [
|
||||||
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
||||||
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
||||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
||||||
// ── 操作(写入,需登录) ──
|
],
|
||||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
},
|
||||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
{
|
||||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||||
{ to: '/admin/config', label: '系统配置', 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 => (
|
|
||||||
<li key={item.to}>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.to}
|
to="/admin"
|
||||||
end={item.end}
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
`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 className="text-base leading-none opacity-50" aria-hidden="true">◇</span>
|
||||||
|
仪表盘
|
||||||
|
</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
|
isActive
|
||||||
? 'bg-press-wash/60 font-medium text-press'
|
? 'bg-press-wash/60 font-medium text-press'
|
||||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<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>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</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"
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<CardHeader
|
||||||
|
title="数据源健康"
|
||||||
|
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 px-4 sm:px-5">
|
||||||
|
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
<Card>
|
||||||
<CardHeader title="已入库联赛" />
|
<CardHeader title="已入库联赛" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[1, 2, 3, 4].map(i => (
|
{dash.leagues.map(l => (
|
||||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : data && data.leagues.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{data.leagues.map(l => (
|
|
||||||
<span
|
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+6
-1
@@ -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))
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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