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:
Simon
2026-09-27 09:01:55 +08:00
parent 82240e383d
commit 9aaca12751
14 changed files with 1103 additions and 209 deletions
+11 -1
View File
@@ -206,6 +206,8 @@ function BacktestInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 提交被拦下过 → 立即展开所有校验错误(否则用户只看到顶部一条,不知道还有哪些字段有问题)
const [revealErrors, setRevealErrors] = useState(false);
const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {};
const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter(
(k) => errors[k]
@@ -216,6 +218,13 @@ function BacktestInner() {
if (!params) return;
if (blocking.length) {
setError(errors[blocking[0]]);
setRevealErrors(true);
// 焦点管理:直接把人带到出问题的控件上,而不是只丢一句提示让人自己找
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(".input--invalid, .field--invalid input");
el?.focus();
el?.scrollIntoView({ block: "center", behavior: "smooth" });
});
return;
}
setRunning(true);
@@ -418,9 +427,10 @@ function BacktestInner() {
showPeriod
disabled={running}
errors={errors}
revealErrors={revealErrors}
/>
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}>
<Btn variant="primary" icon="play" loading={running} disabled={running || blocking.length > 0} onClick={run}>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
{running ? "后台运行中…" : "运行回测"}
</Btn>
<span className="hint">
+20 -10
View File
@@ -320,8 +320,7 @@ function ExperimentsInner() {
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 200 }}
className="input input--search"
placeholder="搜索 ID / 因子 / 摘要"
value={q}
onChange={(e) => {
@@ -331,8 +330,7 @@ function ExperimentsInner() {
aria-label="搜索实验"
/>
<select
className="input"
style={{ width: 130 }}
className="input input--filter"
value={kind}
onChange={(e) => {
setKind(e.target.value);
@@ -358,6 +356,15 @@ function ExperimentsInner() {
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
</div>
) : exps.length === 0 && (q || kind) ? (
/* 筛选无结果 ≠ 没有归档:文案必须区分,否则用户会以为归档全丢了 */
<Empty
icon="search"
title="没有匹配的归档"
hint={`筛选条件:${[q ? `关键词「${q}」` : "", kind ? `类型「${experimentKindLabel(kind)}」` : ""]
.filter(Boolean)
.join(" · ")};共 ${total} 条归档。清除筛选即可看到全部。`}
/>
) : exps.length === 0 ? (
<Empty
icon="archive"
@@ -384,12 +391,15 @@ function ExperimentsInner() {
{exps.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
<td>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
<label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</label>
</td>
<td className="cell-mono cell-strong">{e.id}</td>
<td>{kindView(e.kind)}</td>
+8 -6
View File
@@ -266,12 +266,14 @@ function FactorRow(props: {
<>
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={props.checked}
onChange={props.onToggleCheck}
aria-label={`选择因子 ${f.name}`}
/>
<label className="check check--cell" title={`选择因子 ${f.name}`}>
<input
type="checkbox"
checked={props.checked}
onChange={props.onToggleCheck}
aria-label={`选择因子 ${f.name}`}
/>
</label>
</td>
<td className="cell-strong">
<span className="mono">{f.name}</span>
+183 -14
View File
@@ -51,6 +51,18 @@
--r-md: 12px;
--r-lg: 16px;
/* 控件高度令牌 —— 对齐的根基。
为什么必须显式定高:input / select / button 的原生行高与内边距各不相同,
靠 padding 拼高度会得到 34 / 36.8 / 38.8 三种结果(Chrome 的 date 输入还会
多出 2px),同一行里就参差不齐。显式 height 是唯一能保证等高的做法。 */
--ctl-h-sm: 28px; /* 密集行的小按钮 / 图标按钮 */
--ctl-h-md: 34px; /* 标准控件:输入框 / 下拉 / 按钮 */
--ctl-h-lg: 38px; /* 主操作控件(大号输入框、登录式表单) */
/* 控件横向内边距:与高度同源,保证左右留白一致 */
--ctl-px: 12px;
--ctl-px-sm: 10px;
/* 间距(4px 节奏) */
--sp-1: 4px;
--sp-2: 8px;
@@ -395,7 +407,10 @@ textarea {
background: var(--surface-3);
color: var(--text-1);
border-radius: var(--r-sm);
padding: 7px 14px;
/* 显式高度 + 横向内边距(不再用「上下 padding 拼高度」):
这样按钮与同排的输入框/下拉必然等高,换字体或换浏览器也不会错位。 */
height: var(--ctl-h-md);
padding: 0 var(--ctl-px);
font-size: var(--fs-sm);
font-weight: 500;
cursor: pointer;
@@ -442,7 +457,8 @@ textarea {
}
.btn--sm {
padding: 4px 10px;
height: var(--ctl-h-sm);
padding: 0 var(--ctl-px-sm);
font-size: var(--fs-xs);
border-radius: var(--r-xs);
}
@@ -456,8 +472,8 @@ textarea {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
width: var(--ctl-h-sm);
height: var(--ctl-h-sm);
border-radius: var(--r-sm);
color: var(--text-2);
background: transparent;
@@ -471,21 +487,39 @@ textarea {
color: var(--text-1);
}
/* 大号图标按钮:顶栏等需要更舒服点击目标的位置(仍属于 28/34/38 三级刻度) */
.icon-btn--lg {
width: var(--ctl-h-lg);
height: var(--ctl-h-lg);
}
/* ---------- 表单 ---------- */
.input,
select.input,
textarea.input {
width: 100%;
/* 与 .btn 同一个高度令牌:输入框与按钮同排必然等高。
Chrome 的原生 date 输入比其它控件高 2px,靠 height 一并压平。 */
height: var(--ctl-h-md);
background: #0d1424;
border: 1px solid var(--line-strong);
color: var(--text-1);
border-radius: var(--r-sm);
padding: 7px 10px;
padding: 0 var(--ctl-px-sm);
font-size: var(--fs-sm);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
/* 多行输入不套用控件定高(内容行数决定高度) */
textarea.input {
height: auto;
min-height: 76px;
padding: 8px var(--ctl-px-sm);
line-height: 1.6;
resize: vertical;
}
.input:hover {
border-color: rgba(150, 165, 195, 0.42);
}
@@ -514,19 +548,53 @@ input[type="date"].input {
padding-right: 8px;
}
input[type="checkbox"] {
width: 15px;
height: 15px;
/* 原生勾选控件:尺寸统一到 16px,并把「点击目标」交给外层 label(.check / .radio-row)——
16px 的方块本身永远达不到 28px 触控目标,靠 label 撑开热区才是正确做法。 */
input[type="checkbox"],
input[type="radio"] {
width: 16px;
height: 16px;
margin: 0;
flex: none;
accent-color: var(--accent);
cursor: pointer;
}
/* 单行勾选项(与相邻的下拉/输入同高,保证整行对齐一致) */
.check {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--ctl-h-md);
color: var(--text-2);
font-size: var(--fs-sm);
cursor: pointer;
}
.check:hover {
color: var(--text-1);
}
/* 表格单元里的勾选框:把整个单元做成热区(16px 方块在表格里几乎点不中)。
用 label 包住 → 点文字/空白都能选中,行高即为常规控件高度。 */
.check--cell {
min-height: var(--ctl-h-md);
width: 100%;
justify-content: center;
padding: 0 var(--sp-1);
}
input[type="number"].input,
input[type="date"].input {
width: auto;
min-width: 0;
}
/* 数值/日期字段按内容给宽度:内容只有几位数字,拉满整行既难读也难与相邻控件对齐 */
.form-grid .field > input[type="number"].input {
max-width: 180px;
}
.field {
display: flex;
flex-direction: column;
@@ -539,11 +607,31 @@ input[type="date"].input {
font-size: var(--fs-xs);
font-weight: 500;
letter-spacing: 0.2px;
/* 无标签时也占一行高度(栅格里标签行高不一致会让控件上下错位) */
min-height: 17px;
line-height: 17px;
}
.field__hint {
color: var(--text-3);
font-size: var(--fs-xs);
line-height: 1.5;
/* 提示不裁切:说明文字被 line-clamp 截断等于把信息藏起来。
跨行对齐由 .form-grid 的 align-items:end 保证,不靠裁切文案。 */
}
/* 工具条里的检索框 / 筛选器:宽度由类统一,避免每处写内联像素宽度 */
.input--search {
width: min(240px, 100%);
}
.input--filter {
width: 130px;
}
/* 选项文案较长的选择器(如「代码 名称(收益)」)给更宽的宽度 */
.input--picker {
width: min(320px, 100%);
}
.form-grid {
@@ -553,9 +641,74 @@ input[type="date"].input {
align-items: end;
}
/* 整行字段(长文本框、日期区间、条件编辑器) */
.field--wide {
grid-column: 1 / -1;
}
.form-grid .btn {
align-self: end;
height: 34px;
height: var(--ctl-h-md);
}
/* 小节标题(表单内的分组标题,与 .card__title 区分:更小、不抢层级) */
.form-section__title {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-1);
}
/* 因子行:表头 + 栅格行,靠栅格列宽保证「因子 / 权重 / 操作」三列严格对齐。
反面做法是在 JSX 里给每个控件写死像素宽度(style={{width:220}}),
一旦文案或字体变化就会错位。 */
.factor-rows {
display: grid;
gap: var(--sp-2);
}
.factor-rows__head,
.factor-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) 118px auto;
align-items: center;
gap: var(--sp-2);
}
.factor-rows__head {
color: var(--text-3);
font-size: var(--fs-xs);
padding-bottom: 2px;
border-bottom: 1px solid var(--line);
}
.factor-row .btn {
justify-self: start;
}
/* 选股条件行:与因子行同一套栅格思路(表头 + 成列),
列宽由 CSS 决定,不在 JSX 里写内联像素宽度。 */
.cond-rows {
display: grid;
gap: var(--sp-2);
}
.cond-rows__head,
.cond-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) 84px minmax(96px, 140px) auto;
align-items: center;
gap: var(--sp-2);
}
.cond-rows__head {
color: var(--text-3);
font-size: var(--fs-xs);
padding-bottom: 2px;
border-bottom: 1px solid var(--line);
}
.cond-row .btn {
justify-self: start;
}
.row {
@@ -995,6 +1148,9 @@ table.tbl {
display: inline-flex;
align-items: center;
gap: 6px;
/* 历史记录 chip 是可点击控件:高度必须达到点击目标下限,
不能被 padding 压到 27px 这种「看得见点不准」的尺寸 */
min-height: var(--ctl-h-sm);
background: var(--surface-3);
border: 1px solid var(--line-strong);
border-radius: 999px;
@@ -1002,6 +1158,13 @@ table.tbl {
font-size: var(--fs-xs);
color: var(--text-2);
}
button.chip {
cursor: pointer;
}
button.chip:hover {
border-color: var(--accent);
color: var(--text-1);
}
.chip b {
color: var(--text-1);
@@ -1675,24 +1838,30 @@ table.tbl {
.radio-col {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-1);
}
.radio-row {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 13px;
align-items: center;
gap: var(--sp-2);
/* 点击目标由整行承担(16px 的圆点本身太小),并与 .check 同一高度节奏 */
min-height: var(--ctl-h-sm);
padding: 2px 0;
font-size: var(--fs-sm);
color: var(--text-2);
cursor: pointer;
line-height: 1.45;
}
.radio-row input {
margin-top: 2px;
flex: none;
}
.radio-row:hover {
color: var(--text-1);
}
/* 单选行里的说明文字:与选项标题同一行时用弱化色,不抢视线 */
.radio-row .hint {
color: var(--text-3);
}
/* ---------- 归档详情:元数据条 / KV / 规格表 / 代码块 ---------- */
+142 -101
View File
@@ -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>
</>
)}
+7 -2
View File
@@ -50,6 +50,8 @@ export default function StrategiesPage() {
const [msg, setMsg] = useState("");
const [editing, setEditing] = useState<StrategyParams | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
// 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
const [revealErrors, setRevealErrors] = useState(false);
const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState<string | null>(null);
@@ -101,6 +103,7 @@ export default function StrategiesPage() {
setDocId(null);
setMsg("");
setErr("");
setRevealErrors(false);
}
function startEdit(s: StrategyDefinition) {
@@ -109,6 +112,7 @@ export default function StrategiesPage() {
setDocId(s.id ?? null);
setMsg("");
setErr("");
setRevealErrors(false);
}
async function save() {
@@ -116,6 +120,7 @@ export default function StrategiesPage() {
const errors = validateParams(editing, { requireMeta: true });
if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]);
setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
return;
}
setBusy(true);
@@ -199,6 +204,7 @@ export default function StrategiesPage() {
showPeriod={false}
disabled={busy}
errors={validateParams(editing, { requireMeta: true })}
revealErrors={revealErrors}
/>
<div className="hint" style={{ marginTop: 10 }}>
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
@@ -234,8 +240,7 @@ export default function StrategiesPage() {
title={`已保存策略${list ? ` · ${list.length}` : ""}`}
tools={
<input
className="input"
style={{ width: 220 }}
className="input input--search"
placeholder="搜索策略名 / 说明 / 因子 / 条件"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -223,8 +223,7 @@ export function BacktestResultView({
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input"
style={{ width: 180 }}
className="input input--search"
placeholder="搜索代码或名称"
value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)}
@@ -240,8 +239,8 @@ export function BacktestResultView({
<>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select
className="input"
style={{ width: 280 }}
className="input input--picker"
aria-label="选择要查看的个股"
value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)}
>
+159 -63
View File
@@ -10,6 +10,7 @@
*
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
*/
import { useId, useState } from "react";
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
import { Btn, Field } from "@/components/ui";
@@ -274,6 +275,14 @@ export interface StrategyParamsFormProps {
errors?: Record<string, string>;
/** 因子选择是否允许加权多项(默认允许) */
multiFactor?: boolean;
/**
* 是否立即显示全部校验错误。
*
* 默认 false:只在**字段失焦过**之后才显示该字段的错误。理由:受控输入在用户
* 清空内容准备重填的瞬间就会被判为「至少为 1」,立刻标红属于打扰式提示;
* 提交被拦下时父组件把本值设为 true,确保此时所有问题一次看清。
*/
revealErrors?: boolean;
}
export function StrategyParamsForm({
@@ -285,7 +294,15 @@ export function StrategyParamsForm({
disabled = false,
errors = {},
multiFactor = true,
revealErrors = false,
}: StrategyParamsFormProps) {
// 稳定唯一前缀:同一个页面可能挂两份表单(策略库编辑 + 回测页),
// 写死 id 会撞车,label/for 与 aria 关联就会指错控件。
const fid = useId();
// 失焦过的字段才提示错误(见 revealErrors 说明)
const [touched, setTouched] = useState<Record<string, boolean>>({});
const blur = (key: string) => () => setTouched((t) => ({ ...t, [key]: true }));
const showErr = (key: string) => (revealErrors || touched[key] ? errors[key] : undefined);
const set = (patch: Partial<StrategyParams>) => onChange({ ...p, ...patch });
const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) =>
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
@@ -321,21 +338,25 @@ export function StrategyParamsForm({
disabled={disabled}
onChange={(e) => set({ description: e.target.value })}
/>
{errors.description && (
{showErr("description") && (
<div className="field-err" role="alert">
{errors.description}
{showErr("description")}
</div>
)}
</Field>
</div>
)}
{/* ---------- 因子(打分公式) ---------- */}
{/* ---------- 因子(打分公式) ----------
用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写
内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐;
视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
<b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && (
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
>
@@ -343,12 +364,18 @@ export function StrategyParamsForm({
</Btn>
)}
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 8, marginBottom: 6 }}>
<div className="factor-rows">
<div className="factor-rows__head" aria-hidden="true">
<span>因子</span>
<span>权重</span>
<span />
</div>
{p.factors.map((f, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<div className="factor-row" key={i}>
<select
id={`${fid}-name-${i}`}
className="input"
style={{ width: 220 }}
aria-label={`第 ${i + 1} 个因子的名称`}
value={f.name}
disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })}
@@ -363,27 +390,37 @@ export function StrategyParamsForm({
))}
</select>
<input
id={`${fid}-weight-${i}`}
className="input mono"
style={{ width: 84 }}
type="number"
inputMode="decimal"
step="0.1"
min={0}
aria-label={`第 ${i + 1} 个因子的权重`}
title="权重:越大表示该因子在打分中越重要(1 = 等权)"
value={f.weight}
disabled={disabled}
title="权重"
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/>
{multiFactor && p.factors.length > 1 && (
{multiFactor && p.factors.length > 1 ? (
/* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个因子`}
title="删除该因子"
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
>
删除
</Btn>
/>
) : (
<span />
)}
</div>
))}
</div>
<div className="hint" style={{ marginTop: 6 }}>
权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面
z-score 标准化再加权,避免量纲不同互相压制。
</div>
{errors.factors && (
<div className="field-err" role="alert">
{errors.factors}
@@ -392,66 +429,82 @@ export function StrategyParamsForm({
{/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}>
<Field label="候选池 n(择股条件选出股数)">
<Field
label="候选池 n(择股条件选出股数)"
hint="范围 1~500;n 越大越分散,越小越集中"
>
<input
className="input"
className={`input${errors.topN ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={1}
max={500}
value={p.topN}
disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })}
onBlur={blur("topN")}
/>
{showErr("topN") && (
<div className="field-err" role="alert">
{showErr("topN")}
</div>
)}
</Field>
<Field label="持仓数 x(≤ n)">
<Field label="持仓数 x(≤ n)" hint="最终实际持有只数,通常等于 n">
<input
className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={1}
max={p.topN}
value={p.holdX}
disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })}
onBlur={blur("holdX")}
/>
{errors.holdX && (
{showErr("holdX") && (
<div className="field-err" role="alert">
{errors.holdX}
{showErr("holdX")}
</div>
)}
</Field>
<Field label="择股间隔 m(月,0=跟随 y)" hint="m:多久重新挑一次股">
<Field label="择股间隔 m(月)" hint="多久重新挑一次股;0 = 跟随 y">
<input
className="input"
className={`input${errors.mMonths ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={0}
max={60}
value={p.mMonths}
disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })}
onBlur={blur("mMonths")}
/>
{errors.mMonths && (
{showErr("mMonths") && (
<div className="field-err" role="alert">
{errors.mMonths}
{showErr("mMonths")}
</div>
)}
</Field>
<Field label="调仓间隔 y(月,0=跟随 m)" hint="y:多久按最新选股结果换一次仓">
<Field label="调仓间隔 y(月)" hint="多久按最新选股结果换一次仓;0 = 跟随 m">
<input
className="input"
className={`input${errors.yMonths ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
min={0}
max={60}
value={p.yMonths}
disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })}
onBlur={blur("yMonths")}
/>
{errors.yMonths && (
{showErr("yMonths") && (
<div className="field-err" role="alert">
{errors.yMonths}
{showErr("yMonths")}
</div>
)}
</Field>
<Field label="调仓频率(m、y 均为 0 时生效)">
<Field label="调仓频率" hint="仅当 m、y 都为 0 时生效(否则由 m/y 决定)">
<select
className="input"
value={p.rebalance}
@@ -465,6 +518,7 @@ export function StrategyParamsForm({
<Field
label="买不进时的补位(涨停 / 停牌)"
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
className="field--wide"
>
<div className="radio-col">
{(
@@ -474,10 +528,12 @@ export function StrategyParamsForm({
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const
).map(([val, label, desc]) => (
<label key={val} className="radio-row" title={desc}>
<label key={val} className="radio-row">
<input
type="radio"
name="fill-policy"
/* 同一页面可能挂两份表单:name 必须带表单实例前缀,
否则两边的单选会互相取消选中(原生 radio 按 name 分组) */
name={`${fid}-fill-policy`}
checked={p.fillPolicy === val}
disabled={disabled}
onChange={() => set({ fillPolicy: val })}
@@ -506,21 +562,22 @@ export function StrategyParamsForm({
<option value="none">不复权 none</option>
</select>
</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={p.excludeSt}
disabled={disabled}
onChange={(e) => set({ excludeSt: e.target.checked })}
/>
剔除 ST(按择股日当时名称判定)
剔除 ST
</label>
</Field>
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
<input
className="input"
type="number"
inputMode="numeric"
min={0}
step={10}
value={p.minListingDays}
@@ -528,86 +585,113 @@ export function StrategyParamsForm({
onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/>
</Field>
<Field label="手续费率 %">
<Field
label="手续费率 %"
hint="买卖双向收取;A 股常见 0.025%~0.03%"
>
<input
className="input"
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.commission}
disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })}
/>
</Field>
<Field label="印花税率 %" hint="仅卖出收取">
<Field label="印花税率 %" hint="仅卖出收取;A 股 0.05%(2023 年 8 月起减半)">
<input
className="input"
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.stamp}
disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })}
/>
</Field>
<Field label="滑点率 %">
<Field label="滑点率 %" hint="按成交价的比例估计冲击成本;0.05%~0.1% 较常见">
<input
className="input"
className={`input${errors.costs ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="0.01"
min={0}
max={1}
value={p.slippage}
disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })}
/>
</Field>
<Field label="最低佣金(元/笔)">
<Field label="最低佣金(元/笔)" hint="按笔收取的下限,如 5 元">
<input
className="input"
className={`input${errors.minCommission ? " input--invalid" : ""}`}
type="number"
inputMode="decimal"
step="1"
min={0}
value={p.minCommission}
disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })}
onBlur={blur("minCommission")}
/>
{errors.minCommission && (
{showErr("minCommission") && (
<div className="field-err" role="alert">
{errors.minCommission}
{showErr("minCommission")}
</div>
)}
</Field>
{/* 费率类错误的行内落点:三个费率共用一个错误键(费率不能为负) */}
{errors.costs && (
<div className="field-err" role="alert" style={{ gridColumn: "1 / -1" }}>
{errors.costs}
</div>
)}
{showPeriod && (
<>
<Field label="初始资金(元)">
<Field label="初始资金(元)" hint="回测初始本金,默认 100 万">
<input
className="input"
className={`input${errors.capital ? " input--invalid" : ""}`}
type="number"
inputMode="numeric"
step={100000}
min={10000}
value={p.capital}
disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })}
onBlur={blur("capital")}
/>
{errors.capital && (
{showErr("capital") && (
<div className="field-err" role="alert">
{errors.capital}
{showErr("capital")}
</div>
)}
</Field>
<Field label="开始日期">
<Field
label="开始日期"
hint="回测起始日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input
type="date"
className="input"
className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.start}
disabled={disabled}
onChange={(e) => set({ start: e.target.value })}
/>
</Field>
<Field label="结束日期">
<Field
label="结束日期"
hint="回测结束日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input
type="date"
className="input"
className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.end}
disabled={disabled}
onChange={(e) => set({ end: e.target.value })}
@@ -616,18 +700,21 @@ export function StrategyParamsForm({
</>
)}
</div>
{errors.period && (
{showErr("period") && (
<div className="field-err" role="alert">
{errors.period}
{showErr("period")}
</div>
)}
{/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>选股过滤条件(AND,universe 之后、因子排序之前执行)</b>
<b className="form-section__title">
选股过滤条件(AND,universe 之后、因子排序之前执行)
</b>
<Btn
icon="layers"
size="sm"
disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
>
@@ -637,12 +724,19 @@ export function StrategyParamsForm({
{p.conditions.length === 0 ? (
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div>
) : (
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<div className="cond-rows">
<div className="cond-rows__head" aria-hidden="true">
<span>字段</span>
<span>比较</span>
<span>取值</span>
<span />
</div>
{p.conditions.map((c, i) => (
<div className="row" key={i} style={{ gap: 6 }}>
<div className="cond-row" key={i}>
<input
className="input"
style={{ width: 190 }}
className="input mono"
list={`${fid}-cond-fields`}
aria-label={`第 ${i + 1} 个条件的字段名`}
placeholder="字段(dv_ratio / pe / ma60 …)"
value={c.field}
disabled={disabled}
@@ -656,7 +750,7 @@ export function StrategyParamsForm({
/>
<select
className="input"
style={{ width: 76 }}
aria-label={`第 ${i + 1} 个条件的比较符`}
value={c.op}
disabled={disabled}
onChange={(e) =>
@@ -674,8 +768,10 @@ export function StrategyParamsForm({
))}
</select>
<input
className="input"
style={{ width: 110 }}
className="input mono"
aria-label={`第 ${i + 1} 个条件的取值`}
inputMode="decimal"
placeholder="数值或字段名"
value={String(c.value ?? "")}
disabled={disabled}
onChange={(e) => {
@@ -693,10 +789,10 @@ export function StrategyParamsForm({
<Btn
icon="x"
disabled={disabled}
aria-label={`删除第 ${i + 1} 个条件`}
title="删除该条件"
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
>
删除
</Btn>
/>
</div>
))}
</div>
+1 -2
View File
@@ -131,11 +131,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="topbar">
<button
type="button"
className="icon-btn"
className="icon-btn icon-btn--lg"
aria-label="打开导航菜单"
aria-expanded={open}
onClick={() => setOpen(true)}
style={{ width: 36, height: 36 }}
>
<Icon name="filter" size={18} />
</button>