fix(web): 控件对齐与输入友好整改(控件高度令牌化 + 对齐自检脚本)
问题不是"不好看",而是**可测量的错位**:同一行里原生 date 输入 38.8px、数字输入
36.8px、按钮 34px;16px 的勾选框与 36.8px 的下拉同排;因子行的下拉与权重框没有
可见标签;列表里的勾选框点不中;参数非法时「运行回测」直接置灰且不说原因。
根因:控件高度靠「上下 padding + 行高」拼出来,而 input / select / button 的原生行高
各不相同(Chrome 的 date 还会多 2px),必然参差;加上各处内联像素宽度
(style={{width:220}}、flex:1)与自搓布局,列自然对不齐。
改动:
- 新增控件高度令牌 --ctl-h-sm/md/lg(28/34/38px)与 --ctl-px,.input/select/.btn/
.icon-btn/date 统一显式 height(不再拼 padding);原生 checkbox/radio 统一 16px,
点击热区交给外层 label(.check/.radio-row/.check--cell),表格整格可点
- 因子行、选股条件行改为「表头 + CSS 栅格」成列对齐,列宽由样式决定,
去掉内联像素宽度与 flex 拉伸,配 aria-label 供读屏分辨重复行
- 表单友好化:错误提示改为**失焦或提交后**才出现(清空重填的瞬间不再标红);
提交被拦下时一次展开全部行内错误 + 自动聚焦并滚动到第一个问题字段;
运行/保存按钮不再因参数非法而置灰(灰按钮不说原因 = 看起来不可点却无响应),
改为可点击并讲清原因;补齐 topN/costs 两处「产生了却没人显示」的行内错误落点
- 数值字段补 inputMode/step/min/max 与单位、取值范围提示;工具条检索/筛选用
.input--search/.input--filter/.input--picker 类,不再写内联宽度
- /experiments 筛选无结果的空态与「暂无实验」区分开(原文案会让人以为归档丢了)
- 同一页面可能挂两份表单:radio name 与 label/for 加表单实例前缀(useId),
否则两边单选互相取消、label 指错控件
- 新增 scripts/verify_ui_alignment.py:系统 Chrome + 原生 CDP(仅标准库,
独占随机端口与临时 profile),按 7 个页面 × 1500/375px 检查同排等高、
高度取值归一、点击目标、标签与无障碍名、字号圆角一致、尺寸匹配内容、
横向溢出、提示裁切;本次基线 108/32 → 现 140/140
验证:pytest 388 passed、ruff 全绿(顺带清掉 qlib_verify.py 一处死代码)、
tsc 0 错误、图表单测 7 passed、next build 成功、契约自检 59/59、
对齐自检 140/140(含 375px 小屏)。
This commit is contained in:
+142
-101
@@ -207,8 +207,8 @@ export default function SelectionPage() {
|
||||
<Field label="选股时点 as_of" hint="留空 = 最近可用交易日">
|
||||
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="标的范围">
|
||||
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
|
||||
<Field label="标的范围" hint="ST 按当时名称判定">
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||||
剔除 ST
|
||||
</label>
|
||||
@@ -217,120 +217,161 @@ export default function SelectionPage() {
|
||||
|
||||
{mode === "score" ? (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>评分因子与权重</div>
|
||||
{factorRows.map((r, i) => (
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8 }} key={i}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 2 }}
|
||||
value={r.name}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
next[i] = { ...next[i], name: e.target.value };
|
||||
setFactorRows(next);
|
||||
}}
|
||||
>
|
||||
{factors.map((f) => (
|
||||
<option key={f.name} value={f.name}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
|
||||
评分因子与权重
|
||||
</div>
|
||||
{/* 与回测页同一套「表头 + 栅格行」:列宽由 CSS 决定,不再用 flex 拉伸
|
||||
(flex:1 会把权重框拉到几百像素宽,与一位小数的内容完全不匹配) */}
|
||||
<div className="factor-rows">
|
||||
<div className="factor-rows__head" aria-hidden="true">
|
||||
<span>因子</span>
|
||||
<span>权重</span>
|
||||
<span />
|
||||
</div>
|
||||
{factorRows.map((r, i) => (
|
||||
<div className="factor-row" key={i}>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`第 ${i + 1} 个因子的名称`}
|
||||
value={r.name}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
next[i] = { ...next[i], name: e.target.value };
|
||||
setFactorRows(next);
|
||||
}}
|
||||
>
|
||||
{factors.map((f) => (
|
||||
<option key={f.name} value={f.name}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input mono"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.1"
|
||||
min={0}
|
||||
aria-label={`第 ${i + 1} 个因子的权重`}
|
||||
value={r.weight}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
next[i] = { ...next[i], weight: Number(e.target.value) };
|
||||
setFactorRows(next);
|
||||
}}
|
||||
/>
|
||||
<Btn
|
||||
icon="x"
|
||||
disabled={factorRows.length <= 1}
|
||||
aria-label={`删除第 ${i + 1} 个因子`}
|
||||
title="删除该因子"
|
||||
onClick={() => setFactorRows(factorRows.filter((_, j) => j !== i))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 10, marginTop: 10, alignItems: "flex-end" }}>
|
||||
<Btn
|
||||
icon="layers"
|
||||
size="sm"
|
||||
onClick={() => setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])}
|
||||
>
|
||||
添加因子
|
||||
</Btn>
|
||||
<Field label="Top N" hint="按综合分排序取前 N 只">
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={r.weight}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
next[i] = { ...next[i], weight: Number(e.target.value) };
|
||||
setFactorRows(next);
|
||||
}}
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={factorRows.length <= 1}
|
||||
onClick={() => setFactorRows(factorRows.filter((_, j) => j !== i))}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<button className="btn" onClick={() => setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])}>
|
||||
+ 因子
|
||||
</button>
|
||||
<Field label="Top N">
|
||||
<input className="input" type="number" min={1} value={topN} onChange={(e) => setTopN(Number(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>条件(全部满足才入选)</div>
|
||||
{conds.map((c, i) => {
|
||||
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
|
||||
const isRef = c.field === "close" && c.ref === "ma60";
|
||||
return (
|
||||
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }} key={i}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 2 }}
|
||||
value={isRef ? "close_ma60_ref" : c.field}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
const v = e.target.value;
|
||||
next[i] = v === "close_ma60_ref"
|
||||
? { field: "close", op: "gt", ref: "ma60", value: null }
|
||||
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
|
||||
setConds(next);
|
||||
}}
|
||||
>
|
||||
{FIELD_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
value={c.op}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
next[i] = { ...next[i], op: e.target.value as SelectionCondition["op"] };
|
||||
setConds(next);
|
||||
}}
|
||||
>
|
||||
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{!isRef ? (
|
||||
<input
|
||||
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
|
||||
条件(全部满足才入选)
|
||||
</div>
|
||||
<div className="cond-rows">
|
||||
<div className="cond-rows__head" aria-hidden="true">
|
||||
<span>字段</span>
|
||||
<span>比较</span>
|
||||
<span>取值</span>
|
||||
<span />
|
||||
</div>
|
||||
{conds.map((c, i) => {
|
||||
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
|
||||
const isRef = c.field === "close" && c.ref === "ma60";
|
||||
return (
|
||||
<div className="cond-row" key={i}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
|
||||
value={String(c.value ?? "")}
|
||||
aria-label={`第 ${i + 1} 个条件的字段`}
|
||||
value={isRef ? "close_ma60_ref" : c.field}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
const raw = e.target.value;
|
||||
const val = c.op === "in"
|
||||
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
|
||||
: (meta?.kind === "num" && !c.field.startsWith("static.")
|
||||
? Number(raw)
|
||||
: raw);
|
||||
next[i] = { ...next[i], value: val as never };
|
||||
const v = e.target.value;
|
||||
next[i] = v === "close_ma60_ref"
|
||||
? { field: "close", op: "gt", ref: "ma60", value: null }
|
||||
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
|
||||
setConds(next);
|
||||
}}
|
||||
>
|
||||
{FIELD_OPTIONS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
aria-label={`第 ${i + 1} 个条件的比较符`}
|
||||
value={c.op}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
next[i] = { ...next[i], op: e.target.value as SelectionCondition["op"] };
|
||||
setConds(next);
|
||||
}}
|
||||
>
|
||||
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{!isRef ? (
|
||||
<input
|
||||
className="input mono"
|
||||
aria-label={`第 ${i + 1} 个条件的取值`}
|
||||
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
|
||||
value={String(c.value ?? "")}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
const raw = e.target.value;
|
||||
const val = c.op === "in"
|
||||
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
|
||||
: (meta?.kind === "num" && !c.field.startsWith("static.")
|
||||
? Number(raw)
|
||||
: raw);
|
||||
next[i] = { ...next[i], value: val as never };
|
||||
setConds(next);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* 占位保持第四列(操作列)不串位 */
|
||||
<span />
|
||||
)}
|
||||
<Btn
|
||||
icon="x"
|
||||
disabled={conds.length <= 1}
|
||||
aria-label={`删除第 ${i + 1} 个条件`}
|
||||
title="删除该条件"
|
||||
onClick={() => setConds(conds.filter((_, j) => j !== i))}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
className="btn"
|
||||
disabled={conds.length <= 1}
|
||||
onClick={() => setConds(conds.filter((_, j) => j !== i))}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn" onClick={() => setConds([...conds, emptyCondition()])}>+ 条件</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Btn icon="layers" size="sm" onClick={() => setConds([...conds, emptyCondition()])}>
|
||||
添加条件
|
||||
</Btn>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user