fix+polish(frontend): 预测弹窗打磨 + 全量语义色替换 + 前台交互小项

预测弹窗:
- 修复模板字符串直写 JSX,反引号原样显示在结果面板的 bug
- 入场动画(遮罩淡入+面板上浮);初始聚焦/Tab 焦点陷阱/关闭还焦;
  打开时锁定背景滚动
- 「关闭窗口即取消」→ 如实描述:前端仅停止进度显示,后台任务
  可能仍在执行并消耗额度

语义色:全部 emerald/amber/rose 裸色替换为 ok/warn/bad token
(Standings 分区与走势、KeyRing、定时任务、任务状态点、
数据完整性进度条、预测命中标记、净胜球)。

前台交互:
- 空态跳后台 a[href] → Link,不再整页刷新
- 联赛 tab 溢出右缘渐隐提示 + aria-current
- 状态行右侧计数组加细线分隔,小屏 wrap 后层级仍清晰
This commit is contained in:
WorkBuddy
2026-09-22 16:56:19 +08:00
parent 9c77efaa4b
commit 83a1eed283
7 changed files with 122 additions and 97 deletions
+3 -3
View File
@@ -333,8 +333,8 @@ export default function CollectionPage() {
)} )}
{taskStatus === 'done' && ( {taskStatus === 'done' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-emerald-700"> <div className="flex items-center gap-2 text-xs text-ok-700">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" /> <span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span> <span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
</div> </div>
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>} {summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
@@ -373,7 +373,7 @@ export default function CollectionPage() {
<div> <div>
{recentJobs.map(j => { {recentJobs.map(j => {
const st = j.status 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' ? '执行中' : '排队中' const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
return ( return (
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5"> <div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
@@ -26,9 +26,9 @@ const FIELD_LABELS: Record<string, string> = {
} }
function pctColor(pct: number): string { function pctColor(pct: number): string {
if (pct >= 80) return 'bg-emerald-500' if (pct >= 80) return 'bg-ok-500'
if (pct >= 50) return 'bg-amber-500' if (pct >= 50) return 'bg-warn-500'
return 'bg-rose-500' return 'bg-bad-500'
} }
/** 根据问题描述生成可操作的修复链接 */ /** 根据问题描述生成可操作的修复链接 */
@@ -90,7 +90,7 @@ export default function PredictionHistoryPage() {
</Card> </Card>
<Card> <Card>
<CardBody className="text-center"> <CardBody className="text-center">
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p> <p className="text-2xl font-bold text-ok-700">{hitCount}</p>
<p className="text-xs text-ink-500"></p> <p className="text-xs text-ink-500"></p>
</CardBody> </CardBody>
</Card> </Card>
@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() {
<td className="px-4 py-3"> <td className="px-4 py-3">
{p.pred_1x2 ? ( {p.pred_1x2 ? (
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${ <span className={`inline-block border px-1.5 py-0.5 text-2xs ${
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' : predHit === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
predHit === false ? 'border-press text-press bg-press-wash' : predHit === false ? 'border-press text-press bg-press-wash' :
'border-ink-200 text-ink-600' 'border-ink-200 text-ink-600'
}`}> }`}>
+3 -3
View File
@@ -317,11 +317,11 @@ export default function SettingsPage() {
return ( return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}> <div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} /> <span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span> <span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>} {i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>}
</div> </div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}> <span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'} {isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span> </span>
</div> </div>
@@ -489,7 +489,7 @@ export default function SettingsPage() {
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between"> <div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} /> <span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
<span className="text-xs font-medium text-ink-800">{s.id}</span> <span className="text-xs font-medium text-ink-800">{s.id}</span>
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge> <Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
</div> </div>
+38 -33
View File
@@ -10,9 +10,11 @@
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情 * 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 { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut } from '../admin/types' import type { MatchDetailOut, MatchContextOut } from '../admin/types'
import BackTop from '../components/BackTop'
import { useMatchesList } from './matches/hooks/useMatchesList' import { useMatchesList } from './matches/hooks/useMatchesList'
import { useMatchPredict } from './matches/hooks/useMatchPredict' import { useMatchPredict } from './matches/hooks/useMatchPredict'
import { useLeagues } from './matches/hooks/useLeagues' import { useLeagues } from './matches/hooks/useLeagues'
@@ -38,13 +40,8 @@ export default function Matches() {
loadMore, loadMore,
} = useMatchesList({ onError: setError }) } = useMatchesList({ onError: setError })
const { const { predictingId, prediction, predictionFor, predict, closePredict } =
predictingId, useMatchPredict({ onError: setError })
prediction,
predictionFor,
predict,
closePredict,
} = useMatchPredict({ onError: setError })
// ── 详情展开(懒加载,只读,不触发 LLM) ── // ── 详情展开(懒加载,只读,不触发 LLM) ──
const [expandedId, setExpandedId] = useState<number | null>(null) const [expandedId, setExpandedId] = useState<number | null>(null)
@@ -52,16 +49,8 @@ export default function Matches() {
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({}) const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
const [detailLoading, setDetailLoading] = useState<number | null>(null) const [detailLoading, setDetailLoading] = useState<number | null>(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 const leagueName = leagues.find(l => l.code === league)?.name ?? league
// 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */ /** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled' const isScheduledView = status === 'scheduled'
@@ -90,20 +79,46 @@ export default function Matches() {
} }
} }
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
const leagueNavRef = useRef<HTMLDivElement>(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 ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* ── 联赛版面切换 ── */} {/* ── 联赛版面切换 ── */}
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛"> <div className="relative">
<nav
ref={leagueNavRef}
className="flex items-center gap-6 overflow-x-auto border-b border-ink-900"
aria-label="联赛"
>
{leagues.map(l => ( {leagues.map(l => (
<button <button
key={l.code} key={l.code}
onClick={() => setLeague(l.code)} onClick={() => setLeague(l.code)}
aria-current={league === l.code ? 'true' : undefined}
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`} className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
> >
{l.name} {l.name}
</button> </button>
))} ))}
</nav> </nav>
{canScrollRight && (
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
)}
</div>
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */} {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
@@ -120,7 +135,7 @@ export default function Matches() {
/> />
</span> </span>
<span className="ml-auto inline-flex items-center gap-3"> <span className="ml-auto inline-flex items-center gap-3 border-l border-ink-200 pl-4">
<span className="tabular-nums"> <span className="tabular-nums">
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming {isScheduledView && !showAllUpcoming && hasHiddenUpcoming
? `未来3天 ${visibleMatches.length} / 共 ${matches.length}` ? `未来3天 ${visibleMatches.length} / 共 ${matches.length}`
@@ -203,9 +218,9 @@ export default function Matches() {
<> <>
<p className="empty-state-title"></p> <p className="empty-state-title"></p>
<p className="empty-state-sub"> {leagueName} </p> <p className="empty-state-sub"> {leagueName} </p>
<a href="/admin/collection" className="empty-state-action"> <Link to="/admin/collection" className="empty-state-action">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </Link>
</> </>
)} )}
</div> </div>
@@ -265,18 +280,8 @@ export default function Matches() {
)} )}
</section> </section>
{/* 回到顶部按钮 */} {/* ── 回到顶部(共享组件,方角纸片风) ── */}
<button <BackTop />
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
</div> </div>
) )
} }
+16 -36
View File
@@ -6,6 +6,7 @@
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import BackTop from '../components/BackTop'
import { fetchStandings } from '../admin/dal' import { fetchStandings } from '../admin/dal'
import type { StandingsLeague, StandingRow } from '../admin/dal' import type { StandingsLeague, StandingRow } from '../admin/dal'
import { useLeagues } from './matches/hooks/useLeagues' import { useLeagues } from './matches/hooks/useLeagues'
@@ -13,24 +14,24 @@ import { Spinner } from '../admin/components'
const ZONE_META: Record<string, { label: string; cls: string }> = { const ZONE_META: Record<string, { label: string; cls: string }> = {
// 欧战资格 // 欧战资格
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' }, 'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' }, 'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' }, 'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' }, 'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Conference League Qualification': { 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': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Europa Conference League Qualification': { 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' }, 'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' }, 'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-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 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' }, 'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' }, 'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' }, 'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
'Qualification': { 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) → 彩色圆点 */ /** 近期走势串(W/D/L) → 彩色圆点 */
function FormDots({ form }: { form?: string | null }) { function FormDots({ form }: { form?: string | null }) {
if (!form) return <span className="text-2xs text-ink-400"></span> if (!form) return <span className="text-2xs text-ink-400"></span>
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' } const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
return ( return (
<span className="inline-flex gap-0.5"> <span className="inline-flex gap-0.5">
{form.slice(0, 5).split('').map((c, i) => ( {form.slice(0, 5).split('').map((c, i) => (
@@ -62,17 +63,6 @@ export default function StandingsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false) // 切换联赛中 const [switching, setSwitching] = useState(false) // 切换联赛中
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(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) => { const load = useCallback(async (code?: string) => {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -134,7 +124,7 @@ export default function StandingsPage() {
</div> </div>
{error && ( {error && (
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700"> <div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
{error} {error}
</div> </div>
)} )}
@@ -202,7 +192,7 @@ export default function StandingsPage() {
<td className="text-center py-2 text-ink-500">{r.drawn}</td> <td className="text-center py-2 text-ink-500">{r.drawn}</td>
<td className="text-center py-2 text-ink-500">{r.lost}</td> <td className="text-center py-2 text-ink-500">{r.lost}</td>
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td> <td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}> <td className={`text-center py-2 ${r.goal_diff > 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.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
</td> </td>
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td> <td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
@@ -221,18 +211,8 @@ export default function StandingsPage() {
</div> </div>
)} )}
{/* 回到顶部按钮 */} {/* 回到顶部(共享组件,方角纸片风) */}
<button <BackTop />
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
</div> </div>
) )
} }
@@ -4,7 +4,7 @@
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal; * D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。 * PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
*/ */
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import TeamSideTag from '../../../components/TeamSideTag' import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../types' import type { Match, Prediction } from '../types'
import { AGENT_LABELS } from '../types' import { AGENT_LABELS } from '../types'
@@ -82,7 +82,7 @@ function PredictionPanel({
{!degraded && <OutcomePanel prediction={prediction} match={match} />} {!degraded && <OutcomePanel prediction={prediction} match={match} />}
<p className="text-center text-2xs text-ink-500"> <p className="text-center text-2xs text-ink-500">
`多专家模式 · ${okReports.length}/${reports.length} 路有效` {`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p> </p>
@@ -161,7 +161,8 @@ function PredictProgress() {
</ul> </ul>
<p className="mt-6 text-center text-2xs text-ink-400"> <p className="mt-6 text-center text-2xs text-ink-400">
, 30-90 ; token,使 , 30-90 ; token,使
,
</p> </p>
<p className="mt-1 text-center text-2xs text-ink-300"> <p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 , 提示:每分钟限 10 ,
@@ -185,17 +186,52 @@ export function PredictModal({
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
// ── 无障碍与滚动锁定 ──
const panelRef = useRef<HTMLDivElement>(null)
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => { useEffect(() => {
previouslyFocused.current = document.activeElement as HTMLElement | null
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
panelRef.current?.focus()
const h = (e: KeyboardEvent) => { 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<HTMLElement>(
'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) 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]) }, [onClose])
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center" className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`} aria-label={`预测 ${homeName}${awayName}`}
@@ -203,7 +239,11 @@ export function PredictModal({
if (e.target === e.currentTarget) onClose() if (e.target === e.currentTarget) onClose()
}} }}
> >
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl"> <div
ref={panelRef}
tabIndex={-1}
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
>
{/* 弹窗报头 */} {/* 弹窗报头 */}
<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"> <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">