fix(web): 参数表单收束 + 一句话说明改多行(按截图实测修两处失调)

用户给了真实截图,指出两处肉眼可见的不协调,逐项定位后修复:

1. 「一句话说明」是单行 input 被塞进 ~360px 的格子,placeholder
   「例:全市场股息率最高的 2」直接截断 —— 内容太多、框太小。
   → 改为整宽多行 <textarea>(rows=2、min-height 64px、可纵向拉伸),
     与定宽 340px 的策略名并排(.params-meta flex),窄屏自动上下堆叠;
     实时字数 (N/300) 已在提示里。

2. 「太多地方没有对齐」的根因:表单铺满 ~1900px 内容区,5 列各 ~360px,
   控件拉得太开;因子下拉用 1fr 撑到 ~1500px 而权重框才 118px,比例失调;
   数字输入还有 max-width:180px 上限,比同行下拉窄一截。
   → .params-form 约束到 ~1100px(每列 ~210px,所有控件按 1fr 等宽,
     左右边缘严格对齐);移除已多余的 max-width:180px(限宽后它反而
     制造新的不等宽),数值/日期改为填满单元格;因子行首列封顶 460px、
     整行限宽 720px,条件行同理(首列 ≤360px、限宽 760px);
     复权口径选项去掉括号里的「股息策略推荐」(已在 hint 说明),不再截断。

验证方式也升级了:这次是**截图后用 read_image 看渲染像素**确认,而不只量几何。
对齐自检同步把 textarea(高度随行数变、本就该比单行高)排除出「同排等高 /
高度归一」比较,并把数字过宽阈值调到 240px(限宽后 ~210px 属正常)。

门禁:tsc 0 错误、图表单测 7/7、next build 成功、页面 200、
对齐自检 140/140、契约自检 59/59;/backtest 与 /strategies 截图复核通过。
This commit is contained in:
Simon
2026-09-30 19:56:40 +08:00
parent d1287799f7
commit 50a1030afa
4 changed files with 91 additions and 21 deletions
+10 -4
View File
@@ -456,10 +456,16 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
`height`,而不是靠上下 padding 拼高度 —— 后者会得到 34 / 36.8 / 38.8 三种结果 `height`,而不是靠上下 padding 拼高度 —— 后者会得到 34 / 36.8 / 38.8 三种结果
(Chrome 的原生 `date` 输入还会多 2px),同一行就肉眼可见地参差。 (Chrome 的原生 `date` 输入还会多 2px),同一行就肉眼可见地参差。
**② 尺寸与内容匹配**:数值/日期输入不拉满整行(`.form-grid` 下 `max-width:180px`); **② 尺寸与内容匹配 + 整体收束**:参数表单用 `.params-form` 约束到 **~1100px**
选项文案长的选择器给更宽的类(`.input--picker`);工具条检索/筛选用 (否则在宽屏上铺满 ~1900px、5 列各 ~360px,控件拉得太开且数字框与下拉宽度不一);
`.input--search` / `.input--filter`,**不在 JSX 里写内联像素宽度**;重复行(因子、条件) 限宽后数值/日期输入直接**填满单元格**,与同行下拉等宽 → 左右边缘严格对齐
用"表头 + CSS 栅格"成列对齐,列宽由样式表决定。 (早先在未限宽时用 `max-width:180px` 防数字框过宽,限宽后该上限反而让数字框比
相邻下拉窄一截,故移除)。多行文本(如「一句话说明」,最长 300 字)用
**整宽 `<textarea>`** 而非单行 input —— 单行框必然截断长文案(截图实测
「例:全市场股息率最高的 2」被切掉)。选项文案长的选择器给更宽的类
(`.input--picker`);工具条检索/筛选用 `.input--search` / `.input--filter`,
**不在 JSX 里写内联像素宽度**;重复行(因子、条件)用"表头 + CSS 栅格"成列对齐
且**首列封顶**(因子名 ≤460px),避免一个下拉撑满整行。
**③ 点击目标**:控件 ≥28px;16px 的勾选框/单选按钮由外层 `label`(`.check` / `.radio-row`) **③ 点击目标**:控件 ≥28px;16px 的勾选框/单选按钮由外层 `label`(`.check` / `.radio-row`)
撑开热区,表格里的勾选框用 `.check--cell` 让整个单元可点。 撑开热区,表格里的勾选框用 `.check--cell` 让整个单元可点。
+57 -5
View File
@@ -590,9 +590,12 @@ input[type="date"].input {
min-width: 0; min-width: 0;
} }
/* 数值/日期字段按内容给宽度:内容只有几位数字,拉满整行既难读也难与相邻控件对齐 */ /* 数值/日期字段:在已限宽(.params-form ~1100px)的栅格里直接填满单元格,
.form-grid .field > input[type="number"].input { 与同行的下拉/文本框等宽 → 左右边缘严格对齐。早先的 max-width:180px 在表单
max-width: 180px; 铺满整屏时有意义,限宽后反而让数字框比相邻下拉窄一截(截图实测),故移除。 */
.form-grid .field > input[type="number"].input,
.form-grid .field > input[type="date"].input {
width: 100%;
} }
.field { .field {
@@ -634,6 +637,41 @@ input[type="date"].input {
width: min(320px, 100%); width: min(320px, 100%);
} }
/* ============================================================
参数表单布局:把 sprawling 的多列表单收进一个可读的最大宽度。
------------------------------------------------------------
为什么需要:表单直接铺满 ~1900px 的内容区时,5 列各 ~360px,控件拉得太开、
数字输入和下拉宽度还不一致(截图实测)。约束到 ~1100px 后每列 ~210px,
所有控件按 1fr 等宽 → 同行左右边缘严格对齐,数字也不会宽得离谱。
============================================================ */
.params-form {
max-width: 1100px;
}
/* 策略名 + 一句话说明:定宽名 + 占满剩余宽度的多行说明 */
.params-meta {
display: flex;
gap: var(--sp-4);
align-items: flex-start;
margin-bottom: var(--sp-4);
}
.params-meta__name {
flex: 0 0 340px;
min-width: 0;
}
.params-meta__desc {
flex: 1 1 auto;
min-width: 0;
}
.params-meta__desc textarea.input {
min-height: 64px;
line-height: 1.6;
resize: vertical;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
@@ -669,9 +707,12 @@ input[type="date"].input {
.factor-rows__head, .factor-rows__head,
.factor-row { .factor-row {
display: grid; display: grid;
grid-template-columns: minmax(150px, 1fr) 118px auto; /* 因子名列封顶 460px:再宽就是「momentum_60」配 1500px 下拉的失调(截图实测)。
权重列固定,操作列按内容。整行也限宽,避免在宽屏上拉成一条长线。 */
grid-template-columns: minmax(150px, 460px) 118px auto;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
max-width: 720px;
} }
.factor-rows__head { .factor-rows__head {
@@ -690,12 +731,13 @@ input[type="date"].input {
.cond-rows { .cond-rows {
display: grid; display: grid;
gap: var(--sp-2); gap: var(--sp-2);
max-width: 760px;
} }
.cond-rows__head, .cond-rows__head,
.cond-row { .cond-row {
display: grid; display: grid;
grid-template-columns: minmax(150px, 1fr) 84px minmax(96px, 140px) auto; grid-template-columns: minmax(150px, 360px) 84px minmax(96px, 160px) auto;
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
@@ -1449,6 +1491,16 @@ button.chip:hover {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
/* 窄屏:策略名与说明上下堆叠,名占满整行 */
.params-meta {
flex-direction: column;
gap: var(--sp-3);
}
.params-meta__name {
flex: 1 1 auto;
width: 100%;
}
.page-head { .page-head {
flex-direction: column; flex-direction: column;
} }
+16 -9
View File
@@ -308,10 +308,13 @@ export function StrategyParamsForm({
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) }); set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
return ( return (
<> <div className="params-form">
{showMeta && ( {showMeta && (
<div className="form-grid"> /* 策略名(定宽)+ 一句话说明(占满剩余宽度、多行)并排:
<Field label="策略名(必填)" hint="在策略库中唯一"> 说明最长 300 字,塞进单行 input 必然截断(截图实测「例:全市场股息率最高的 2」
就被切掉),所以这里用 textarea 并给足高度。 */
<div className="params-meta">
<Field label="策略名(必填)" hint="在策略库中唯一" className="params-meta__name">
<input <input
className="input" className="input"
value={p.name} value={p.name}
@@ -319,24 +322,28 @@ export function StrategyParamsForm({
placeholder="例:高股息 6 月择股 · 低频" placeholder="例:高股息 6 月择股 · 低频"
disabled={disabled} disabled={disabled}
onChange={(e) => set({ name: e.target.value })} onChange={(e) => set({ name: e.target.value })}
onBlur={blur("name")}
/> />
{errors.name && ( {showErr("name") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.name} {showErr("name")}
</div> </div>
)} )}
</Field> </Field>
<Field <Field
label="一句话说明(必填)" label="一句话说明(必填)"
hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`} hint={`说清这个策略做什么、怎么选股(${p.description.length}/300,落库列宽上限)`}
className="params-meta__desc"
> >
<input <textarea
className="input" className={`input${showErr("description") ? " input--invalid" : ""}`}
rows={2}
value={p.description} value={p.description}
maxLength={300} maxLength={300}
placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有" placeholder="例:全市场股息率最高的 20 只,每 6 个月重新择股并等权持有"
disabled={disabled} disabled={disabled}
onChange={(e) => set({ description: e.target.value })} onChange={(e) => set({ description: e.target.value })}
onBlur={blur("description")}
/> />
{showErr("description") && ( {showErr("description") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
@@ -557,7 +564,7 @@ export function StrategyParamsForm({
disabled={disabled} disabled={disabled}
onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })} onChange={(e) => set({ priceAdjustment: e.target.value as "none" | "qfq" | "hfq" })}
> >
<option value="hfq">后复权 hfq(股息策略推荐)</option> <option value="hfq">后复权 hfq</option>
<option value="qfq">前复权 qfq</option> <option value="qfq">前复权 qfq</option>
<option value="none">不复权 none</option> <option value="none">不复权 none</option>
</select> </select>
@@ -808,6 +815,6 @@ export function StrategyParamsForm({
(财务按公告日 ≤ 择股日取用)。 (财务按公告日 ≤ 择股日取用)。
</div> </div>
</div> </div>
</> </div>
); );
} }
+8 -3
View File
@@ -213,6 +213,9 @@ PROBE = r"""
aria: !!(e.getAttribute("aria-label") || e.getAttribute("title")), aria: !!(e.getAttribute("aria-label") || e.getAttribute("title")),
iconOnlyBtn: e.tagName === "BUTTON" && !txt(e.innerText) && !e.getAttribute("data-label"), iconOnlyBtn: e.tagName === "BUTTON" && !txt(e.innerText) && !e.getAttribute("data-label"),
nestedIcon: nested && e.classList.contains("icon-btn"), nestedIcon: nested && e.classList.contains("icon-btn"),
// 多行文本域:高度随行数变化,是「内容决定高度」的控件,
// 不参与「同排等高」(它本来就该比单行输入高)与「高度取值归一」比较
isTextarea: e.tagName === "TEXTAREA",
x: r.x, y: r.y, w: r.w, h: r.h, fs: parseFloat(s.fontSize), x: r.x, y: r.y, w: r.w, h: r.h, fs: parseFloat(s.fontSize),
radius: s.borderRadius, padL: parseFloat(s.paddingLeft), padR: parseFloat(s.paddingRight), radius: s.borderRadius, padL: parseFloat(s.paddingLeft), padR: parseFloat(s.paddingRight),
placeholder: txt(e.getAttribute("placeholder")).slice(0, 30), placeholder: txt(e.getAttribute("placeholder")).slice(0, 30),
@@ -317,7 +320,7 @@ def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
# 1 同排等高 # 1 同排等高
mixed = [] mixed = []
for row in rows: for row in rows:
heights = [c["hitH"] for c in row if not c["nestedIcon"]] heights = [c["hitH"] for c in row if not c["nestedIcon"] and not c["isTextarea"]]
if len(heights) < 2: if len(heights) < 2:
continue continue
if max(heights) - min(heights) > 1.5: if max(heights) - min(heights) > 1.5:
@@ -329,7 +332,7 @@ def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高")) out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高"))
# 2 高度取值归一 # 2 高度取值归一
uniq = sorted({round(c["hitH"], 1) for c in ctrls if not c["optionRow"]}) uniq = sorted({round(c["hitH"], 1) for c in ctrls if not c["optionRow"] and not c["isTextarea"]})
out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}")) out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}"))
# 3 点击目标 # 3 点击目标
@@ -364,7 +367,9 @@ def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
out.append((len(radii) <= 2, "控件圆角 ≤2 种", f"{radii}")) out.append((len(radii) <= 2, "控件圆角 ≤2 种", f"{radii}"))
# 7 尺寸匹配内容 # 7 尺寸匹配内容
wide_num = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("number", "date") and c["w"] > 220] # 阈值 240:参数表单约束到 ~1100px 后每列约 210px,数字输入填满单元格属正常;
# 真正失调的是早先 flex:1 拉到 359px 那种(截图实测)。
wide_num = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("number", "date") and c["w"] > 240]
narrow = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("text", "search", "") narrow = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("text", "search", "")
and c["w"] < 120 and not c["nestedIcon"]] and c["w"] < 120 and not c["nestedIcon"]]
det = [] det = []