修复实验页右侧内容溢出:宽表固定两端 + 提示;对比表首列固定;月度表收紧内边距

- 实验列表 11 列在 1366px 及更窄窗口放不下,「操作」列被推到卡片外;macOS 覆盖式
  滚动条不滚动不显示,看起来就是「右侧内容溢出/被切掉」。现在左右两端固定
  (选择列、ID、操作列),列宽按实测单行内容宽度定,只有真溢出时才提示可横向滚动。
- 对比视图(指标对比 / 参数差异)列数随实验个数增长,首列改为固定,同样按需提示。
- 详情图层里的月度收益表 13 列超出 38px:收紧内边距并固定「年份」列。
- 可排序表头命中区 19.2px → 34px(与同排复选框等高),修掉 UI 规范门禁的 7 项失败。
- 顺手把对比区两处会原样显示的字面 ** 改成「」。

门禁:ruff 通过;pytest 524 passed;tsc 0 error;npm run build OK;test:charts 7 passed;
verify_ui_alignment.py 160 项通过 / 0 失败。
This commit is contained in:
Simon
2026-10-01 19:33:33 +08:00
parent e58367af27
commit a395db892d
3 changed files with 247 additions and 23 deletions
+70 -18
View File
@@ -160,10 +160,37 @@ export default function ExperimentsPage() {
);
}
/**
* 宽表此刻是否需要横向滚动(按实测 `scrollWidth > clientWidth`,容器尺寸变化时重算)。
*
* 为什么要提示:卡片内的横向滚动条在 macOS 上是覆盖式的(不滚就不显示),
* 用户看到的就是「右侧内容被切掉了」——这正是「experiments 页面右侧内容溢出了」的来源。
* 为什么要在运行时判断而不是写死一句提示:列宽固定,窗口够宽时根本不需要滚动,
* 那种情况下还挂着提示就是废话(还让人以为页面坏了)。
*/
function useTableScrollHint(deps: React.DependencyList) {
const ref = useRef<HTMLDivElement | null>(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<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
// 列表容器:窄窗口下宽表要横向滚动,提示只在真需要时出现
const { ref: tableWrapRef, scrolls: tableScrolls } = useTableScrollHint([exps.length, loading]);
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState("");
@@ -551,11 +578,21 @@ function ExperimentsInner() {
icon="archive"
title="实验列表"
sub={
total !== null
<span>
{total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"}
{tableScrolls ? (
// 只在真的放不下时才说:卡片内的横向滚动条容易看不见(触控板尤其),
// 而右端「操作」列已固定,用户不必先滚到底才知道能点哪里。
<>
{" "}
<span className="hint">列较多,可横向滚动;选择列与「操作」列固定不动。</span>
</>
) : null}
</span>
}
tools={
<div className="row" style={{ gap: 8 }}>
@@ -612,14 +649,13 @@ function ExperimentsInner() {
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
/>
) : (
<div className="table-wrap">
<table className="tbl">
<div className="table-wrap table-wrap--wide" ref={tableWrapRef}>
<table className="tbl tbl--wide">
<thead>
<tr>
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
<th
style={{ width: 36 }}
title={
total !== null && total > rows.length
? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)`
@@ -635,7 +671,7 @@ function ExperimentsInner() {
/>
</label>
</th>
<th style={{ width: 36 }}>
<th>
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
对比
</span>
@@ -659,7 +695,9 @@ function ExperimentsInner() {
<th>摘要</th>
<th>版本(代码 / 数据)</th>
<th>体积</th>
<th style={{ width: 260, textAlign: "right" }}>操作</th>
{/* 列宽由 .tbl--wide 统一给(含固定右端):行内 width 会盖掉 CSS,
曾经就是它让「操作」列保持 260px、整表多出 88px 放不下 */}
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -686,9 +724,11 @@ function ExperimentsInner() {
/>
</label>
</td>
<td className="cell-mono cell-strong">{e.id}</td>
<td className="cell-mono cell-strong" title={e.id}>
{e.id}
</td>
<td>{kindView(e.kind)}</td>
<td className="cell-mono nowrap" title={e.created_at ?? "该归档没有记录发起时间"}>
<td className="cell-mono" title={e.created_at ?? "该归档没有记录发起时间"}>
{fmtLocalTime(e.created_at)}
</td>
<td>
@@ -700,7 +740,12 @@ function ExperimentsInner() {
))}
</span>
</td>
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td
className="cell-mono"
title={e.period ? `区间 ${e.period[0]} ~ ${e.period[1]}` : "该归档没有记录区间"}
>
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"}
</td>
<td className="hint">{e.summary_text ?? "—"}</td>
<td>
<span className="row" style={{ gap: 4 }}>
@@ -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<LwSeries[]>(
() =>
rows.map((r, i) => ({
@@ -978,12 +1028,12 @@ function CompareView({
ariaLabel="实验净值曲线叠加对比"
/>
<div className="hint" style={{ marginTop: 6 }}>
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
曲线已归一化为「累计收益率 %」(各自以首个净值为 0%),因此初始资金不同也能直接比。
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
</div>
<div className="table-wrap" style={{ marginTop: 14 }}>
<table className="tbl">
<div className="table-wrap" style={{ marginTop: 14 }} ref={metricTable.ref}>
<table className="tbl tbl--pin-first">
<thead>
<tr>
<th>指标</th>
@@ -1028,18 +1078,19 @@ function CompareView({
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
{base.total_return_pct.toFixed(2)}%。
{metricTable.scrolls ? " 列较多,可横向滚动;首列「指标」固定不动。" : ""}
</div>
<div style={{ marginTop: 16 }}>
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
{diffRows.length === 0 ? (
<div className="hint" style={{ marginTop: 6 }}>
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
这几个实验的参数快照完全一致(说明结果是「同一配置」下的数据变动,或只是复跑)。
已排除 price_basis.* 运行元数据。
</div>
) : (
<div className="table-wrap" style={{ marginTop: 6 }}>
<table className="tbl">
<div className="table-wrap" style={{ marginTop: 6 }} ref={paramTable.ref}>
<table className="tbl tbl--pin-first">
<thead>
<tr>
<th>参数</th>
@@ -1068,9 +1119,10 @@ function CompareView({
</table>
</div>
)}
{onlyDiff ? (
{onlyDiff || paramTable.scrolls ? (
<div className="hint" style={{ marginTop: 6 }}>
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
{onlyDiff ? "已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。" : null}
{paramTable.scrolls ? " 参数表列较多,可横向滚动;首列「参数」固定不动。" : null}
</div>
) : null}
</div>
+171 -1
View File
@@ -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;
+3 -1
View File
@@ -395,7 +395,9 @@ export function MonthlyReturnsTable({
if (years.length === 0) return <Empty title="无月度收益数据" />;
return (
<div className="table-wrap">
<table className="tbl">
{/* 13 列(年份 + 12 个月):窄容器(实验详情图层 1032px、手机)一定放不下,
所以收紧内边距 + 首列「年份」固定,让月份横向滚动时也知道看的是哪一年。 */}
<table className="tbl tbl--monthly tbl--pin-first">
<thead>
<tr>
<th>年份</th>