diff --git a/frontend/web/app/experiments/page.tsx b/frontend/web/app/experiments/page.tsx index d3ba25a..82c7d0c 100644 --- a/frontend/web/app/experiments/page.tsx +++ b/frontend/web/app/experiments/page.tsx @@ -160,10 +160,37 @@ export default function ExperimentsPage() { ); } +/** + * 宽表此刻是否需要横向滚动(按实测 `scrollWidth > clientWidth`,容器尺寸变化时重算)。 + * + * 为什么要提示:卡片内的横向滚动条在 macOS 上是覆盖式的(不滚就不显示), + * 用户看到的就是「右侧内容被切掉了」——这正是「experiments 页面右侧内容溢出了」的来源。 + * 为什么要在运行时判断而不是写死一句提示:列宽固定,窗口够宽时根本不需要滚动, + * 那种情况下还挂着提示就是废话(还让人以为页面坏了)。 + */ +function useTableScrollHint(deps: React.DependencyList) { + const ref = useRef(null); + const [scrolls, setScrolls] = useState(false); + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setScrolls(el.scrollWidth > el.clientWidth + 1); + check(); + const ro = new ResizeObserver(check); + ro.observe(el); + return () => ro.disconnect(); + // deps 由调用方给(行数 / 筛选变化时要重新量) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return { ref, scrolls }; +} + function ExperimentsInner() { const search = useSearchParams(); const [exps, setExps] = useState([]); const [loading, setLoading] = useState(true); + // 列表容器:窄窗口下宽表要横向滚动,提示只在真需要时出现 + const { ref: tableWrapRef, scrolls: tableScrolls } = useTableScrollHint([exps.length, loading]); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); @@ -551,11 +578,21 @@ function ExperimentsInner() { icon="archive" title="实验列表" sub={ - total !== null - ? `显示 ${exps.length} 条 / 共 ${total} 条${ - total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : "" - }` - : "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比" + + {total !== null + ? `显示 ${exps.length} 条 / 共 ${total} 条${ + total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : "" + }` + : "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"} + {tableScrolls ? ( + // 只在真的放不下时才说:卡片内的横向滚动条容易看不见(触控板尤其), + // 而右端「操作」列已固定,用户不必先滚到底才知道能点哪里。 + <> + {" "} + 列较多,可横向滚动;选择列与「操作」列固定不动。 + + ) : null} + } tools={
@@ -612,14 +649,13 @@ function ExperimentsInner() { hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。" /> ) : ( -
- +
+
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开, 因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */} - - + {/* 列宽由 .tbl--wide 统一给(含固定右端):行内 width 会盖掉 CSS, + 曾经就是它让「操作」列保持 260px、整表多出 88px 放不下 */} + @@ -686,9 +724,11 @@ function ExperimentsInner() { /> - + - - +
rows.length ? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)` @@ -635,7 +671,7 @@ function ExperimentsInner() { /> + 对比 @@ -659,7 +695,9 @@ function ExperimentsInner() { 摘要 版本(代码 / 数据) 体积操作操作
{e.id} + {e.id} + {kindView(e.kind)} + {fmtLocalTime(e.created_at)} @@ -700,7 +740,12 @@ function ExperimentsInner() { ))} {e.period ? `${e.period[0]}~${e.period[1]}` : "—"} + {e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"} + {e.summary_text ?? "—"} @@ -924,6 +969,11 @@ function CompareView({ setOnlyDiff: (v: boolean) => void; onClose: () => void; }) { + // 指标对比 / 参数差异两张表都是「一行一个指标 + 每个实验一列」:列数随对比个数增长, + // 窄窗口必然横向滚动。首列(指标名 / 参数名)必须固定,否则滚到右边不知道这一行是什么。 + const metricTable = useTableScrollHint([rows.length]); + const paramTable = useTableScrollHint([rows.length, onlyDiff]); + const series = useMemo( () => rows.map((r, i) => ({ @@ -978,12 +1028,12 @@ function CompareView({ ariaLabel="实验净值曲线叠加对比" />
- 曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。 + 曲线已归一化为「累计收益率 %」(各自以首个净值为 0%),因此初始资金不同也能直接比。 点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
-
- +
+
@@ -1028,18 +1078,19 @@ function CompareView({ 差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差 (收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "} {base.total_return_pct.toFixed(2)}%。 + {metricTable.scrolls ? " 列较多,可横向滚动;首列「指标」固定不动。" : ""}
参数差异(config_snapshot 逐项对比) {diffRows.length === 0 ? (
- 这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。 + 这几个实验的参数快照完全一致(说明结果是「同一配置」下的数据变动,或只是复跑)。 已排除 price_basis.* 运行元数据。
) : ( -
-
指标
+
+
@@ -1068,9 +1119,10 @@ function CompareView({
参数
)} - {onlyDiff ? ( + {onlyDiff || paramTable.scrolls ? (
- 已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。 + {onlyDiff ? "已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。" : null} + {paramTable.scrolls ? " 参数表列较多,可横向滚动;首列「参数」固定不动。" : null}
) : null} diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css index 89010b2..6236efc 100644 --- a/frontend/web/app/globals.css +++ b/frontend/web/app/globals.css @@ -1078,6 +1078,168 @@ table.tbl { color: var(--warn); } +/* 月度收益表:13 列,默认内边距(9px 12px)下要 1070px,装不进 1032px 的详情图层, + 右侧会被切掉 38px(就是「右侧内容溢出」的样子)。收到 8px 6px 后 13 列省下约 150px。 */ +.tbl--monthly th, +.tbl--monthly td { + padding: 8px 6px; +} + +/* ---------- 对比表(指标对比 / 参数差异):首列固定 ---------- + 两张表都是「一行一个指标/参数 + 每个实验一列」,列数随对比个数增长,窄窗口必然横向滚动。 + 首列(指标名、参数名)不固定的话,滚到右边看到一串数字却不知道对应哪一项。 */ +.tbl--pin-first th:first-child, +.tbl--pin-first td:first-child { + position: sticky; + left: 0; + z-index: 2; + background: var(--surface-1); +} +.tbl--pin-first thead th:first-child { + z-index: 4; + background: #131c2e; +} +.tbl--pin-first tbody tr:hover td:first-child { + background: #17202f; +} + +/* ---------- 宽表(实验列表):固定两端 + 中间横向滚动 ---------- + 实验列表有 11 列(选择 / 对比 / ID / 类型 / 发起时间 / 因子 / 区间 / 摘要 / 版本 / 体积 / 操作)。 + 1366px 及更窄的窗口一屏放不下:不给右端「操作」列做固定,它会被推到卡片外 —— + 用户看到的是「右侧内容溢出了」,勾了「删除」却找不到「详情 / 复跑 / 打开归档」。 + 所以:左端两列选择框与 ID、右端操作列**固定**,中间列横向滚动; + 同时用固定列宽 + 收紧内边距,让 1366px 以上仍然一屏放得下(不出现没必要的滚动条)。 + 固定列必须有不透明底色,否则中间列会从下面透出来(卡片是渐变,这里取近似纯色)。 */ +.table-wrap--wide { + overflow-x: auto; +} +.tbl--wide { + table-layout: fixed; +} +.tbl--wide th, +.tbl--wide td { + padding: 8px 9px; +} +.tbl--wide th:nth-child(1), +.tbl--wide td:nth-child(1) { + width: 42px; +} +.tbl--wide th:nth-child(2), +.tbl--wide td:nth-child(2) { + width: 42px; +} +.tbl--wide th:nth-child(3), +.tbl--wide td:nth-child(3) { + width: 117px; +} +.tbl--wide th:nth-child(4), +.tbl--wide td:nth-child(4) { + width: 62px; +} +.tbl--wide th:nth-child(5), +.tbl--wide td:nth-child(5) { + width: 104px; +} +.tbl--wide th:nth-child(6), +.tbl--wide td:nth-child(6) { + width: 117px; +} +.tbl--wide th:nth-child(7), +.tbl--wide td:nth-child(7) { + width: 104px; +} +.tbl--wide th:nth-child(8), +.tbl--wide td:nth-child(8) { + width: 140px; +} +.tbl--wide th:nth-child(9), +.tbl--wide td:nth-child(9) { + width: 137px; +} +/* 这个表头文字比列宽长(「版本(代码 / 数据)」),允许它折两行; + 其它 th 保持 nowrap,否则表头会挤成多行、列宽失控 */ +.tbl--wide th:nth-child(9) { + white-space: normal; + line-height: 1.3; +} +.tbl--wide th:nth-child(10), +.tbl--wide td:nth-child(10) { + width: 71px; +} +.tbl--wide th:nth-child(11), +.tbl--wide td:nth-child(11) { + width: 186px; + text-align: right; +} +/* 版本列的数据指纹 chip 很长(d20260904;n≈7688k;a≈7922k;b≈7718k): + 不设上限就会横向顶进「操作」列(看起来就是「右侧内容溢出」)。这里在单元格内省略, + 完整值仍在 chip 的 title 里(悬停可见),不靠截断字符串假装完整。 */ +.tbl--wide td:nth-child(9) .row { + min-width: 0; + flex-wrap: wrap; +} +.tbl--wide td:nth-child(9) .tag, +.tbl--wide td:nth-child(9) .hint { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: bottom; +} + +/* ID:等宽字符串不能折行(折成 "EXP-"/"8EA2819B" 就没法扫读),宽度不够时省略号 + + title 给完整值。发起时间与区间**允许折行**:它们按「日期 / 时刻」「起 / 止」自然断行, + 比折在日期中间("2020-01-")可读,也让这两列不必占满一行的宽度。 */ +.tbl--wide td:nth-child(3) { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 左端固定:选择(删除)/ 对比 / ID。left 偏移 = 前面各固定列的宽度之和, + 所以上面的列宽改动必须同步改这里的 left(table-layout: fixed 下宽度是确定的)。 */ +.tbl--wide th:nth-child(-n + 3), +.tbl--wide td:nth-child(-n + 3) { + position: sticky; + z-index: 2; + background: var(--surface-1); +} +.tbl--wide th:nth-child(1), +.tbl--wide td:nth-child(1) { + left: 0; +} +.tbl--wide th:nth-child(2), +.tbl--wide td:nth-child(2) { + left: 38px; +} +.tbl--wide th:nth-child(3), +.tbl--wide td:nth-child(3) { + left: 76px; +} +/* 右端固定:操作列。加一点内阴影,滚动时能看出「这一列是浮在上面的」 */ +.tbl--wide th:nth-child(11), +.tbl--wide td:nth-child(11) { + position: sticky; + right: 0; + z-index: 2; + background: var(--surface-1); + box-shadow: -10px 0 12px -12px rgba(0, 0, 0, 0.9); +} +.tbl--wide thead th { + z-index: 4; + background: #131c2e; +} +/* 行 hover / 选中:固定列的底色也要跟着变,否则整行高亮时两端是「断开」的 */ +.tbl--wide tbody tr:hover td:nth-child(-n + 3), +.tbl--wide tbody tr:hover td:nth-child(11) { + background: #17202f; +} +.tbl--wide tbody tr.row-active td:nth-child(-n + 3), +.tbl--wide tbody tr.row-active td:nth-child(11) { + background: #17253d; +} + /* 扩展行 / 详情内嵌 */ .expand-cell { background: rgba(10, 15, 28, 0.6); @@ -2269,6 +2431,9 @@ button.chip:hover { color: var(--accent-strong); font-weight: 600; } +/* 可排序表头:本体必须和同一行里的控件(如「全选本页」复选框,命中区 34px)等高, + 否则 19.2px 高的点击目标既难点中,也会被 UI 规范门禁判为「同排控件不等高」。 + 行高不因此变化:表头行本来就由 34px 的复选框决定。 */ .th-sort { appearance: none; border: 0; @@ -2276,7 +2441,12 @@ button.chip:hover { color: inherit; font: inherit; cursor: pointer; - padding: 0; + padding: 0 6px; + min-height: var(--ctl-h-md); + border-radius: var(--r-sm); +} +.th-sort:hover { + background: rgba(150, 165, 195, 0.075); } .reason-cell { display: flex; diff --git a/frontend/web/components/ui.tsx b/frontend/web/components/ui.tsx index bda8d16..d7304ec 100644 --- a/frontend/web/components/ui.tsx +++ b/frontend/web/components/ui.tsx @@ -395,7 +395,9 @@ export function MonthlyReturnsTable({ if (years.length === 0) return ; return (
- + {/* 13 列(年份 + 12 个月):窄容器(实验详情图层 1032px、手机)一定放不下, + 所以收紧内边距 + 首列「年份」固定,让月份横向滚动时也知道看的是哪一年。 */} +
年份