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 (
|