From b9beddacf7f3fb1d0e16193b067c704397df599c Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 16:55:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(design):=20ok/warn/bad=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E8=89=B2=20token=20+=20btn-outline=20+=20=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=8A=A8=E6=95=88=20keyframes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tailwind 新增语义状态色三组(纸底文字级 600/700 对比度 ≥4.5:1,AA), 色相降饱和贴近墨色印刷感;后续替换全部裸 tailwind 原色(emerald/amber/rose) - 补 btn-outline 定义:此前三处使用但从未定义,样式静默失效 - 弹窗入场动画 keyframes:遮罩淡入 0.18s + 面板上浮 0.2s,仅 opacity/transform --- frontend/src/index.css | 22 +++++++++++++++++++++ frontend/tailwind.config.js | 39 +++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 3f13dfa..56fccdd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -103,6 +103,28 @@ .btn-danger { @apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50; } + /* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合 + (此前三处使用但从未定义,样式静默失效) */ + .btn-outline { + @apply border-ink-900 bg-transparent text-ink-900 + hover:border-press hover:bg-press-wash hover:text-press-dark; + } + + /* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */ + .modal-overlay-enter { + animation: modal-fade 0.18s ease-out both; + } + .modal-panel-enter { + animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both; + } + @keyframes modal-fade { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes modal-rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } + } /* ── 统一空态 ── */ .empty-state { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 539a452..1addd4a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -24,12 +24,39 @@ export default { 800: '#282420', 900: '#17140F', }, - // 印报红:全站唯一强调色,克制使用 - press: { - DEFAULT: '#9E1B1B', - dark: '#7C1414', - wash: '#F7E9E4', - }, + // 印报红:全站唯一强调色,克制使用 + press: { + DEFAULT: '#9E1B1B', + dark: '#7C1414', + wash: '#F7E9E4', + }, + // ── 语义状态色:成功/警告/负面 ── + // 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA), + // 点/条级(500) ≥ 3:1;色相降饱和以贴近墨色印刷感,不使用 tailwind 原色。 + ok: { + 50: '#EAF3ED', + 100: '#D5E8DC', + 300: '#A3C9B1', + 500: '#43925F', + 600: '#2E7A4C', + 700: '#1F6B45', + }, + warn: { + 50: '#FBF3E4', + 100: '#F5E5C8', + 300: '#E2C48E', + 500: '#D97706', + 600: '#A15C0B', + 700: '#8F5109', + }, + bad: { + 50: '#FAEDED', + 100: '#F6E3E3', + 300: '#E4AFAF', + 500: '#C24A4A', + 600: '#A83B3B', + 700: '#8F3030', + }, }, fontFamily: { // 毛体草书(国内 CDN)+ 粗楷体回退 -- 2.39.5 From 9c77efaa4bdc9bf42b9f01ac98ed31486ac54869 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 16:55:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(frontend):=20=E6=8A=A5=E5=A4=B4?= =?UTF-8?q?=E6=8A=BD=E7=BB=84=E4=BB=B6=20Masthead=20+=20=E5=9B=9E=E5=88=B0?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E6=8A=BD=E7=BB=84=E4=BB=B6=20BackTop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Masthead:统一 HomePage/StandingsLayout 双份复制实现,当前版面 印报红高亮 + aria-current;⚙ 字符改线条 SVG;管理类入口加 title 提示需登录,访客不再被无声踢到登录页 - BackTop:两页各一份的浮动按钮收敛为共享组件,去 rounded-full/ shadow-lg,与全站方角无阴影语言对齐 --- frontend/src/App.tsx | 63 ++--------------------- frontend/src/components/BackTop.tsx | 30 +++++++++++ frontend/src/components/Masthead.tsx | 77 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/BackTop.tsx create mode 100644 frontend/src/components/Masthead.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 352d475..bdc9cdd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,48 +11,17 @@ * - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败) */ -import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ErrorBoundary } from './components/ErrorBoundary' +import Masthead from './components/Masthead' import Matches from './pages/Matches' import Standings from './pages/Standings' import { adminRoutes } from './admin/routes' -/** 报眉日期行 */ -function dateLine(): string { - return new Date().toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'long', - }) -} - function StandingsLayout({ children }: { children: React.ReactNode }) { return (
-
-
-
-

- 先知 -

-
-
- {dateLine()} - -
-
-
+
{children} @@ -70,30 +39,8 @@ function StandingsLayout({ children }: { children: React.ReactNode }) { function HomePage() { return (
- {/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */} -
-
-
-

- 先知 -

-
-
- {dateLine()} - -
-
-
+ {/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */} +
diff --git a/frontend/src/components/BackTop.tsx b/frontend/src/components/BackTop.tsx new file mode 100644 index 0000000..45eb8f4 --- /dev/null +++ b/frontend/src/components/BackTop.tsx @@ -0,0 +1,30 @@ +/** + * 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。 + * 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角 + * 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。 + */ +import { useEffect, useState } from 'react' + +export default function BackTop({ threshold = 300 }: { threshold?: number }) { + const [show, setShow] = useState(false) + + useEffect(() => { + const handleScroll = () => setShow(window.scrollY > threshold) + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, [threshold]) + + return ( + + ) +} diff --git a/frontend/src/components/Masthead.tsx b/frontend/src/components/Masthead.tsx new file mode 100644 index 0000000..60c15ae --- /dev/null +++ b/frontend/src/components/Masthead.tsx @@ -0,0 +1,77 @@ +/** + * 报头组件(前台共用)。 + * + * 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移 + * (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。 + * 现在统一为 Masthead: + * - active 声明当前版面,对应导航项加印报红高亮 + aria-current + * - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙) + * - 管理类入口带 title 提示,避免访客被无声踢到登录页 + * 页脚文案各页不同,仍由调用方自行渲染。 + */ +import { Link } from 'react-router-dom' + +export type MastheadActive = 'home' | 'standings' + +function GearIcon() { + return ( + + ) +} + +function MastheadLink({ + to, + active = false, + title, + children, +}: { + to: string + /** 当前版面才高亮;管理类入口无高亮概念 */ + active?: boolean + title?: string + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +export default function Masthead({ active }: { active: MastheadActive }) { + return ( +
+
+
+

+ 先知 +

+
+
+ {new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })} + +
+
+
+ ) +} -- 2.39.5 From 83a1eed2830ad1f006779c6da6b02c3165c54b65 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 16:56:19 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix+polish(frontend):=20=E9=A2=84=E6=B5=8B?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=89=93=E7=A3=A8=20+=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E8=89=B2=E6=9B=BF=E6=8D=A2=20+=20=E5=89=8D?= =?UTF-8?q?=E5=8F=B0=E4=BA=A4=E4=BA=92=E5=B0=8F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预测弹窗: - 修复模板字符串直写 JSX,反引号原样显示在结果面板的 bug - 入场动画(遮罩淡入+面板上浮);初始聚焦/Tab 焦点陷阱/关闭还焦; 打开时锁定背景滚动 - 「关闭窗口即取消」→ 如实描述:前端仅停止进度显示,后台任务 可能仍在执行并消耗额度 语义色:全部 emerald/amber/rose 裸色替换为 ok/warn/bad token (Standings 分区与走势、KeyRing、定时任务、任务状态点、 数据完整性进度条、预测命中标记、净胜球)。 前台交互: - 空态跳后台 a[href] → Link,不再整页刷新 - 联赛 tab 溢出右缘渐隐提示 + aria-current - 状态行右侧计数组加细线分隔,小屏 wrap 后层级仍清晰 --- frontend/src/admin/pages/Collection.tsx | 6 +- frontend/src/admin/pages/DataCompleteness.tsx | 6 +- .../src/admin/pages/PredictionHistory.tsx | 4 +- frontend/src/admin/pages/Settings.tsx | 6 +- frontend/src/pages/Matches.tsx | 91 ++++++++++--------- frontend/src/pages/Standings.tsx | 52 ++++------- .../matches/components/MatchPredictPanel.tsx | 54 +++++++++-- 7 files changed, 122 insertions(+), 97 deletions(-) diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx index 0f1cadf..b078349 100644 --- a/frontend/src/admin/pages/Collection.tsx +++ b/frontend/src/admin/pages/Collection.tsx @@ -333,8 +333,8 @@ export default function CollectionPage() { )} {taskStatus === 'done' && (
-
- +
+ 采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}
{summary &&

{summary.detail}

} @@ -373,7 +373,7 @@ export default function CollectionPage() {
{recentJobs.map(j => { const st = j.status - const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse' + const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse' const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中' return (
diff --git a/frontend/src/admin/pages/DataCompleteness.tsx b/frontend/src/admin/pages/DataCompleteness.tsx index 46aa2fb..04fd5b3 100644 --- a/frontend/src/admin/pages/DataCompleteness.tsx +++ b/frontend/src/admin/pages/DataCompleteness.tsx @@ -26,9 +26,9 @@ const FIELD_LABELS: Record = { } function pctColor(pct: number): string { - if (pct >= 80) return 'bg-emerald-500' - if (pct >= 50) return 'bg-amber-500' - return 'bg-rose-500' + if (pct >= 80) return 'bg-ok-500' + if (pct >= 50) return 'bg-warn-500' + return 'bg-bad-500' } /** 根据问题描述生成可操作的修复链接 */ diff --git a/frontend/src/admin/pages/PredictionHistory.tsx b/frontend/src/admin/pages/PredictionHistory.tsx index b38b9be..3b3fef8 100644 --- a/frontend/src/admin/pages/PredictionHistory.tsx +++ b/frontend/src/admin/pages/PredictionHistory.tsx @@ -90,7 +90,7 @@ export default function PredictionHistoryPage() { -

{hitCount}

+

{hitCount}

命中

@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() { {p.pred_1x2 ? ( diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index 94d9b55..fa3cbcd 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -317,11 +317,11 @@ export default function SettingsPage() { return (
- + {k.masked} {i === keyRing.active_index && 当前}
- + {isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
@@ -489,7 +489,7 @@ export default function SettingsPage() {
- + {s.id} {s.task}
diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx index 15f2f9f..274fdde 100644 --- a/frontend/src/pages/Matches.tsx +++ b/frontend/src/pages/Matches.tsx @@ -10,9 +10,11 @@ * matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情 * 本文件只负责状态装配与版面组织,不含数据获取与展示细节。 */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' import { fetchMatchDetail, fetchMatchContext } from '../admin/dal' import type { MatchDetailOut, MatchContextOut } from '../admin/types' +import BackTop from '../components/BackTop' import { useMatchesList } from './matches/hooks/useMatchesList' import { useMatchPredict } from './matches/hooks/useMatchPredict' import { useLeagues } from './matches/hooks/useLeagues' @@ -38,13 +40,8 @@ export default function Matches() { loadMore, } = useMatchesList({ onError: setError }) - const { - predictingId, - prediction, - predictionFor, - predict, - closePredict, - } = useMatchPredict({ onError: setError }) + const { predictingId, prediction, predictionFor, predict, closePredict } = + useMatchPredict({ onError: setError }) // ── 详情展开(懒加载,只读,不触发 LLM) ── const [expandedId, setExpandedId] = useState(null) @@ -52,16 +49,8 @@ export default function Matches() { const [contextMap, setContextMap] = useState>({}) const [detailLoading, setDetailLoading] = useState(null) - // 监听滚动,超过 300px 显示回到顶部按钮 - const [showBackTop, setShowBackTop] = useState(false) - useEffect(() => { - const handleScroll = () => setShowBackTop(window.scrollY > 300) - window.addEventListener('scroll', handleScroll, { passive: true }) - return () => window.removeEventListener('scroll', handleScroll) - }, []) - const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) - const leagueName = leagues.find(l => l.code === league)?.name ?? league + // 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件 /** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */ const isScheduledView = status === 'scheduled' @@ -90,20 +79,46 @@ export default function Matches() { } } + // 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示 + const leagueNavRef = useRef(null) + const [canScrollRight, setCanScrollRight] = useState(false) + useEffect(() => { + const el = leagueNavRef.current + if (!el) return + const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8) + update() + el.addEventListener('scroll', update, { passive: true }) + window.addEventListener('resize', update) + return () => { + el.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + } + }, [leagues]) + return (
{/* ── 联赛版面切换 ── */} - +
+ + {canScrollRight && ( + {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
@@ -120,7 +135,7 @@ export default function Matches() { /> - + {isScheduledView && !showAllUpcoming && hasHiddenUpcoming ? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场` @@ -203,9 +218,9 @@ export default function Matches() { <>

本版暂无赛程

请先通过「数据采集」导入 {leagueName} 的比赛数据

- + 前往数据采集 - + )}
@@ -265,18 +280,8 @@ export default function Matches() { )} - {/* 回到顶部按钮 */} - + {/* ── 回到顶部(共享组件,方角纸片风) ── */} +
) } diff --git a/frontend/src/pages/Standings.tsx b/frontend/src/pages/Standings.tsx index 90bc615..18ac183 100644 --- a/frontend/src/pages/Standings.tsx +++ b/frontend/src/pages/Standings.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useState, useCallback } from 'react' +import BackTop from '../components/BackTop' import { fetchStandings } from '../admin/dal' import type { StandingsLeague, StandingRow } from '../admin/dal' import { useLeagues } from './matches/hooks/useLeagues' @@ -13,24 +14,24 @@ import { Spinner } from '../admin/components' const ZONE_META: Record = { // 欧战资格 - 'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' }, - 'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' }, - 'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' }, + 'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' }, + 'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' }, + 'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' }, 'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' }, 'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' }, 'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' }, 'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' }, // 升级 - 'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' }, - 'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' }, - 'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' }, + 'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' }, + 'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' }, + 'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' }, // 降级 - 'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' }, + 'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' }, 'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' }, - 'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' }, + 'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' }, // 附加赛 - 'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' }, - 'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' }, + 'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' }, + 'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' }, 'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' }, 'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' }, } @@ -44,7 +45,7 @@ function zoneBadge(zone?: string | null) { /** 近期走势串(W/D/L) → 彩色圆点 */ function FormDots({ form }: { form?: string | null }) { if (!form) return - const colorMap: Record = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' } + const colorMap: Record = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' } return ( {form.slice(0, 5).split('').map((c, i) => ( @@ -62,17 +63,6 @@ export default function StandingsPage() { const [loading, setLoading] = useState(true) const [switching, setSwitching] = useState(false) // 切换联赛中 const [error, setError] = useState(null) - const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态 - - // 监听滚动,超过 300px 显示回到顶部按钮 - useEffect(() => { - const handleScroll = () => setShowBackTop(window.scrollY > 300) - window.addEventListener('scroll', handleScroll, { passive: true }) - return () => window.removeEventListener('scroll', handleScroll) - }, []) - - const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) - const load = useCallback(async (code?: string) => { setLoading(true) setError(null) @@ -134,7 +124,7 @@ export default function StandingsPage() {
{error && ( -
+
{error}
)} @@ -202,7 +192,7 @@ export default function StandingsPage() { {r.drawn} {r.lost} {r.goals_for}/{r.goals_against} - 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}> + 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}> {r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff} {r.points} @@ -221,18 +211,8 @@ export default function StandingsPage() {
)} - {/* 回到顶部按钮 */} - + {/* 回到顶部(共享组件,方角纸片风) */} +
) } diff --git a/frontend/src/pages/matches/components/MatchPredictPanel.tsx b/frontend/src/pages/matches/components/MatchPredictPanel.tsx index 5fa9460..4409412 100644 --- a/frontend/src/pages/matches/components/MatchPredictPanel.tsx +++ b/frontend/src/pages/matches/components/MatchPredictPanel.tsx @@ -4,7 +4,7 @@ * D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal; * PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。 */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import TeamSideTag from '../../../components/TeamSideTag' import type { Match, Prediction } from '../types' import { AGENT_LABELS } from '../types' @@ -82,7 +82,7 @@ function PredictionPanel({ {!degraded && }

- `多专家模式 · ${okReports.length}/${reports.length} 路有效` + {`多专家模式 · ${okReports.length}/${reports.length} 路有效`} {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}

@@ -161,7 +161,8 @@ function PredictProgress() {

- 五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消 + 五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。 + 关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。

提示:每分钟限 10 次预测,耗尽后需等待下一分钟。 @@ -185,17 +186,52 @@ export function PredictModal({ const homeName = match.home_team_zh || match.home_team const awayName = match.away_team_zh || match.away_team + // ── 无障碍与滚动锁定 ── + const panelRef = useRef(null) + const previouslyFocused = useRef(null) + useEffect(() => { + previouslyFocused.current = document.activeElement as HTMLElement | null + // 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件 + panelRef.current?.focus() + const h = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + onClose() + return + } + if (e.key === 'Tab') { + // 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面 + const focusables = panelRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + if (!focusables || focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } } document.addEventListener('keydown', h) - return () => document.removeEventListener('keydown', h) + // 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面 + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', h) + document.body.style.overflow = prevOverflow + // 关闭后把焦点还给触发元素 + previouslyFocused.current?.focus() + } }, [onClose]) return (

-
+
{/* 弹窗报头 */}

-- 2.39.5