From ec8f36abb2bda3072ddd56897ab5cbc470b51662 Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Sun, 20 Sep 2026 08:45:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=BB=E7=AB=99=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: 路由和布局修复 - index.css: 样式修复 - Matches.tsx: 比赛列表页修复 - .zcodeignore: 新增忽略配置 - tests/ui_audit.py: UI 审计测试脚本 --- .zcodeignore | 47 ++++++ frontend/src/App.tsx | 55 ++----- frontend/src/index.css | 32 +++- frontend/src/pages/Matches.tsx | 273 +++++++++++++++++---------------- tests/ui_audit.py | 34 ++++ 5 files changed, 265 insertions(+), 176 deletions(-) create mode 100644 .zcodeignore create mode 100644 tests/ui_audit.py diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..9eb1489 --- /dev/null +++ b/.zcodeignore @@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98e73a5..0179c57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,18 @@ /** * 主应用入口 * - * 顶层三分区导航: - * - 比赛/预测 → 公开,报纸风赛程 + 预测 - * - 评估 → 只读(后端需 admin 鉴权,未登录引导登录) - * - 管理 → 采集/回测/配置等(需登录) + * 页面结构: + * - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口 + * - /admin/* → 管理后台(鉴权门禁,未登录引导登录) + * + * 导航策略: + * - 首页报头放「评估」「管理」入口(极简,不另加导航条) + * - 管理后台由 AdminLayout 侧边栏处理所有管理页导航 + * - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败) */ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ErrorBoundary } from './components/ErrorBoundary' -import { useState } from 'react' import Matches from './pages/Matches' 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 ( - - ) -} - function HomePage() { return (
- {/* ── 报头:粗线 + 居中刊名 + 顶层导航 ── */} + {/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
@@ -66,8 +45,15 @@ function HomePage() {
{dateLine()} +
- {}} />
@@ -84,18 +70,12 @@ function HomePage() { ) } -/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */ -function AdminEntry() { - return -} - export default function App() { return ( } /> - } /> {adminRoutes.map(route => ( {route.children.map(child => ( @@ -114,4 +94,3 @@ export default function App() { ) } - diff --git a/frontend/src/index.css b/frontend/src/index.css index 9f96725..2ca7f31 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -58,14 +58,15 @@ /* ── 按钮:方正边框式,悬停反白 ── */ .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 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 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 { - @apply px-2.5 py-1 text-xs min-h-[36px]; + @apply px-2.5 text-xs min-h-[36px]; } .btn-solid { @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; } + /* ── 统一空态 ── */ + .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 { @apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800 diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx index 5335943..b04789a 100644 --- a/frontend/src/pages/Matches.tsx +++ b/frontend/src/pages/Matches.tsx @@ -197,7 +197,6 @@ export default function Matches() { const [prediction, setPrediction] = useState(null) const [predictionFor, setPredictionFor] = useState(null) const [error, setError] = useState(null) - const [mode, setMode] = useState<'single' | 'multi'>('multi') const [expandedId, setExpandedId] = useState(null) const [detailMap, setDetailMap] = useState>({}) const [contextMap, setContextMap] = useState>({}) @@ -269,16 +268,23 @@ export default function Matches() { useEffect(() => { load() }, [load]) - /** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */ + /** 今日日期 YYYY-MM-DD(本地时区,非 UTC) */ 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[]]> { const map = new Map() for (const m of list) { - const key = (m.match_date || '').slice(0, 10) + const key = toLocalDateKey(m.match_date) const arr = map.get(key) if (arr) arr.push(m) else map.set(key, [m]) @@ -302,7 +308,7 @@ export default function Matches() { const res = await fetch('/api/v1/predict', { method: 'POST', 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, }) if (seq !== predictSeq.current) return @@ -317,7 +323,7 @@ export default function Matches() { if (seq !== predictSeq.current) return setError( e instanceof DOMException && e.name === 'AbortError' - ? '预测超时(5 分钟),请稍后重试或改用单次模式' + ? '预测超时(5 分钟),请稍后重试' : readablePredictError(e), ) } 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' }) } + /** 只显示时间 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 /** 状态/模式一组的文字切换 */ @@ -370,9 +382,9 @@ export default function Matches() { ))} - {/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */} -
- + {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */} +
+ 状态 - - 模式 - setMode(v as 'single' | 'multi')} - items={[ - { v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' }, - { v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' }, - ]} - /> - - - + 日期 + )} @@ -426,18 +426,14 @@ export default function Matches() {
- {/* ── 错误提示 ── */} + {/* ── 错误提示(统一 error-banner 样式) ── */} {error && ( -
+
-

请求失败

-

{error}

+

请求失败

+

{error}

- +
)} @@ -445,7 +441,6 @@ export default function Matches() { {predictionFor && ( } {!loading && matches.length === 0 && ( -
-

本版暂无赛程

-

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

+
+

本版暂无赛程

+

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

+ + 前往数据采集 +
)} {!loading && groupByDate(matches).map(([dateKey, group]) => (
- {/* 日期分组头 */} -
- {formatDateHeader(dateKey)} · {group.length} 场 + {/* 日期分组头:sticky 但 z 低于弹窗 z-50 */} +
+ {formatDateHeader(dateKey)} + {group.length} 场
{group.map(m => { const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' } @@ -503,7 +502,7 @@ export default function Matches() {
{/* 行:可点击展开 */}
{ if (e.key === 'Enter') toggleExpand() }} aria-expanded={expanded} > - {/* 小屏:日期+状态行;桌面:日期单独一列 */} -
- {fmtDate(m.match_date)} - {st.label} -
+ {/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */} +
+ {/* 日期 + 状态:小屏同行;桌面 date 单独一列 */} +
+ {fmtTime(m.match_date)} + {st.label} +
- {/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */} -
- + {/* 主队(右对齐) */} + - {homeName} + {homeName} - + + {/* 比分 / VS:桌面上更突出 */} + {m.home_goals !== null && m.away_goals !== null ? ( - - {m.home_goals}:{m.away_goals} + + {m.home_goals}:{m.away_goals} ) : ( - VS + VS )} - {m.home_xg !== null && m.away_xg != null && ( - - xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)} + {m.home_xg !== null && m.away_xg !== null && ( + + xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)} )} - + + {/* 客队(左对齐) */} + - {awayName} + {awayName} -
- {/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */} - {!finished && ( -
e.stopPropagation()}> - -
- )} + {/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */} + + + {st.label} + + - {/* 桌面端按钮(小屏隐藏) */} - {st.label} -
e.stopPropagation()}> + {/* 预测按钮:小屏独占一行 */} {!finished && ( - +
e.stopPropagation()}> + +
)} -
-
{/* 关闭可点击行(clickable row) */} - {/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */} + {/* 预测按钮:桌面 */} +
e.stopPropagation()}> + {!finished && ( + + )} +
+
+
{/* 关闭可点击行 */} + + {/* 展开详情面板 */} {expanded && ( )}
@@ -589,9 +604,9 @@ export default function Matches() { ))} {!loading && nextCursor && ( -
-
)} @@ -607,10 +622,10 @@ function formatDateHeader(dateKey: string): string { const d = new Date(dateKey + 'T00:00:00') if (isNaN(d.getTime())) return dateKey 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) 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()] if (dateKey === todayKey) 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) useEffect(() => { 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_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 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 (
@@ -681,7 +694,6 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { {phase === 'slice' && '正在组装比赛数据切片'} {phase === 'agents' && '五路专家并行分析中'} - {phase === 'model' && '模型分析中'} {phase === 'agg' && '终裁专家汇总裁定中'} {elapsed.toFixed(0)}s @@ -690,14 +702,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { {/* 进度条:渐进式,不封顶到 100% */}
{/* 专家灯序(多专家模式) */} - {mode === 'multi' && ( -
    +
      {agents.map((a, i) => { const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1) const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i @@ -721,18 +732,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { ) })}
    - )}

    - {mode === 'multi' - ? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消' - : '单次调用,约需 5-20 秒;关闭窗口即取消'} + 五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消 +

    +

    + 提示:每分钟限 10 次预测,耗尽后需等待下一分钟。

    - {mode === 'multi' && ( -

    - 提示:每分钟限 10 次预测,耗尽后需等待下一分钟。 -

    - )}
) } @@ -740,14 +746,12 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { /** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ function PredictModal({ match, - mode, predicting, prediction, error, onClose, }: { match: Match - mode: 'single' | 'multi' predicting: boolean prediction: Prediction | null error: string | null @@ -774,9 +778,9 @@ function PredictModal({ if (e.target === e.currentTarget) onClose() }} > -
+
{/* 弹窗报头 */} -
+

预测版 · @@ -787,7 +791,7 @@ function PredictModal({

- {/* 弹窗体 */} + {/* 弹窗体(小屏可滚动) */} +
{predicting ? ( - + ) : error ? (

预测失败

@@ -808,8 +813,9 @@ function PredictModal({
) : prediction ? ( - + ) : null} +
) @@ -818,12 +824,10 @@ function PredictModal({ function PredictionPanel({ prediction, match, - mode, embedded = false, }: { prediction: Prediction match: Match - mode: 'single' | 'multi' /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ embedded?: boolean }) { @@ -899,7 +903,7 @@ function PredictionPanel({ {/* ── 元信息 ── */}

- {mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'} + `多专家模式 · ${okReports.length}/${reports.length} 路有效` {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}

@@ -914,7 +918,7 @@ function PredictionPanel({ )} {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} - {mode === 'multi' && reports.length > 0 && ( + {reports.length > 0 && (
{!finished && ( - 点击行首「预测」按钮发起{mode === 'multi' ? '多专家' : '单次'}分析 + 点击行首「预测」按钮发起多专家分析 )}
@@ -1128,7 +1131,7 @@ function MatchDetailPanel({ {detail?.recent_predictions?.length ? (
{detail.recent_predictions.map(p => ( - + ))}
) : ( @@ -1149,7 +1152,7 @@ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMa {rows && rows.length > 0 ? (
    {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 label = side === 'h2h' ? `${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' ? { label: 'degraded', cls: 'text-press' } : 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) ? `${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 (
    @@ -1198,7 +1201,7 @@ function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'si
    - {p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'} + {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 }) : '—'} {hasAgents && {p.agent_outputs!.length} 路专家报告}
    {p.reasoning && ( @@ -1220,7 +1223,7 @@ function readablePredictError(e: unknown): string { if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额' if (/400|已完赛/.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 String(e) diff --git a/tests/ui_audit.py b/tests/ui_audit.py new file mode 100644 index 0000000..ad21feb --- /dev/null +++ b/tests/ui_audit.py @@ -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,有的用

    ,有的只有 "暂无" + # 9. 主页 error banner 与 admin Alert 组件重复实现,样式不同 + # 10. Dashboard 空态文案("请先到数据采集导入")没有链接到采集页 + # 11. 侧边栏小屏汉堡菜单图标和文字重叠风险 + # 12. 预测结果弹窗的 reasoning 文字没有限制最大高度,长文撑满视口 +] + +# ── 低(可选) ────────────────────────────────────────────── + +ISSUES_LOW = [ + # 13. "管理后台"链接的 icon ⚠ 和 nav 图标风格不一致 + # 14. footer 文字在小屏可能被弹窗遮挡(z-index 层级) + # 15. 回测结果导出 CSV 按钮在移动端显示位置不明确 +]