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
@@ -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 && <OutcomePanel prediction={prediction} match={match} />}
<p className="text-center text-2xs text-ink-500">
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
@@ -161,7 +161,8 @@ function PredictProgress() {
</ul>
<p className="mt-6 text-center text-2xs text-ink-400">
, 30-90 ; token,使
, 30-90 ; token,使
,
</p>
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 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<HTMLDivElement>(null)
const previouslyFocused = useRef<HTMLElement | null>(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<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)
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 (
<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"
aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`}
@@ -203,7 +239,11 @@ export function PredictModal({
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">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">