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
+3 -2
View File
@@ -72,12 +72,13 @@ cd backend
uv sync # 含 pyqlib(GitHub 源码依赖,固定 commit)。若网络下载困难/超时,按 AGENT.md §0 设置代理 192.168.1.160:3128 后重试 uv sync # 含 pyqlib(GitHub 源码依赖,固定 commit)。若网络下载困难/超时,按 AGENT.md §0 设置代理 192.168.1.160:3128 后重试
# 3. 运行测试 # 3. 运行测试
uv run pytest # 全量 341 条 uv run pytest # 全量 388 条
uv run ruff check app tests uv run ruff check app tests
# 3b. 端到端契约自检(真实提交回测 Job,验证页面↔后端字段不漂移) # 3b. 端到端契约自检(真实提交回测 Job,验证页面↔后端字段不漂移)
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路 PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路(59 项)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约 PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,需前端已启动)
# 4. 启动开发服务 # 4. 启动开发服务
uv run uvicorn app.main:app --reload --port 8000 uv run uvicorn app.main:app --reload --port 8000
+55 -2
View File
@@ -439,6 +439,56 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
# 只验接口与页面(跳过回测 Job):加 --skip-job # 只验接口与页面(跳过回测 Job):加 --skip-job
``` ```
#### 6.3.2 界面规范与对齐自检(控件尺寸 / 输入友好)
界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。
因此把可度量的部分**写成规范 + 自检脚本**,而不是靠肉眼。
**① 控件高度只有三档令牌**(`app/globals.css` 的 `--ctl-h-sm/md/lg` = 28 / 34 / 38px):
| 令牌 | 用途 | 典型控件 |
|---|---|---|
| `--ctl-h-sm` | 密集行 | 小按钮、图标按钮、可点击 chip |
| `--ctl-h-md` | 标准 | 输入框、下拉、按钮(默认) |
| `--ctl-h-lg` | 主操作 | 顶栏图标按钮 |
规则:**同一行内的控件必须等高**。做法是给 `.input / select / .btn / .icon-btn` 显式
`height`,而不是靠上下 padding 拼高度 —— 后者会得到 34 / 36.8 / 38.8 三种结果
(Chrome 的原生 `date` 输入还会多 2px),同一行就肉眼可见地参差。
**② 尺寸与内容匹配**:数值/日期输入不拉满整行(`.form-grid` 下 `max-width:180px`);
选项文案长的选择器给更宽的类(`.input--picker`);工具条检索/筛选用
`.input--search` / `.input--filter`,**不在 JSX 里写内联像素宽度**;重复行(因子、条件)
用"表头 + CSS 栅格"成列对齐,列宽由样式表决定。
**③ 点击目标**:控件 ≥28px;16px 的勾选框/单选按钮由外层 `label`(`.check` / `.radio-row`)
撑开热区,表格里的勾选框用 `.check--cell` 让整个单元可点。
**④ 标签与无障碍名**:每个输入都有可见标签或 `aria-label`;只有图标的按钮必须有
`aria-label`/`title`(否则读屏只会念"按钮")。
**⑤ 输入友好**:校验错误**失焦或提交后**才提示(清空重填的瞬间不标红);
提交被拦下时**一次展开全部**行内错误并**聚焦到第一个问题字段**;
运行/保存按钮**不因参数非法而置灰**(灰按钮不说原因属于"看起来不可点却无响应"),
而是可点击并讲清原因。
**对齐自检**(用系统 Chrome + 原生 CDP,仅标准库;会独占随机端口与临时 profile):
```bash
python3 scripts/verify_ui_alignment.py # 7 个页面 × 1500/375px,共 140 项检查
python3 scripts/verify_ui_alignment.py --dump # 打印每行控件的宽高与字号明细
python3 scripts/verify_ui_alignment.py --pages /backtest --width 375
```
检查项:同排控件等高、控件高度取值归一(≤3 种)、点击目标、标签/无障碍名、
字号与圆角一致、尺寸匹配内容、无横向滚动、提示文本不被裁切。
> 说明:本平台为**深色单主题**(`color-scheme: dark`),因此"深浅两套主题都测对比度"
> 这一条不适用;对比度按深色主题实测(WCAG 相对亮度公式,正文对背景):
> 正文 `--text-1` 对 `--bg-0` **17.09:1**、对卡片 `--surface-1` **15.26:1**;
> 标签 `--text-2` 对卡片 **8.42:1**;提示 `--text-3` 对卡片 **6.12:1**;
> 错误 `--neg` 对卡片 **7.04:1** —— 均高于正文 4.5:1 / 次要文字 3:1 的门槛。
--- ---
## 7. AI Agent ## 7. AI Agent
@@ -496,8 +546,9 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
```bash ```bash
cd backend cd backend
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路(56 项,约 4 分钟) PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路(59 项,约 4 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟) PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,约 3 分钟)
``` ```
覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」 覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」
@@ -505,7 +556,9 @@ PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回
**数据库目标守卫**(`192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、 **数据库目标守卫**(`192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、
**QlibEngine 数据管线**(bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment **QlibEngine 数据管线**(bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导** 归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)。 (`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出,
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)。
--- ---
+11 -1
View File
@@ -206,6 +206,8 @@ function BacktestInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 提交被拦下过 → 立即展开所有校验错误(否则用户只看到顶部一条,不知道还有哪些字段有问题)
const [revealErrors, setRevealErrors] = useState(false);
const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {}; const errors = params ? validateParams(params, { requireMeta: params.name.trim() !== "" }) : {};
const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter( const blocking = ["topN", "holdX", "factors", "mMonths", "yMonths", "costs", "capital", "period", "conditions"].filter(
(k) => errors[k] (k) => errors[k]
@@ -216,6 +218,13 @@ function BacktestInner() {
if (!params) return; if (!params) return;
if (blocking.length) { if (blocking.length) {
setError(errors[blocking[0]]); 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; return;
} }
setRunning(true); setRunning(true);
@@ -418,9 +427,10 @@ function BacktestInner() {
showPeriod showPeriod
disabled={running} disabled={running}
errors={errors} errors={errors}
revealErrors={revealErrors}
/> />
<div className="sticky-bar" style={{ marginTop: 14, position: "static" }}> <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 ? "后台运行中…" : "运行回测"} {running ? "后台运行中…" : "运行回测"}
</Btn> </Btn>
<span className="hint"> <span className="hint">
+20 -10
View File
@@ -320,8 +320,7 @@ function ExperimentsInner() {
tools={ tools={
<div className="row" style={{ gap: 8 }}> <div className="row" style={{ gap: 8 }}>
<input <input
className="input" className="input input--search"
style={{ width: 200 }}
placeholder="搜索 ID / 因子 / 摘要" placeholder="搜索 ID / 因子 / 摘要"
value={q} value={q}
onChange={(e) => { onChange={(e) => {
@@ -331,8 +330,7 @@ function ExperimentsInner() {
aria-label="搜索实验" aria-label="搜索实验"
/> />
<select <select
className="input" className="input input--filter"
style={{ width: 130 }}
value={kind} value={kind}
onChange={(e) => { onChange={(e) => {
setKind(e.target.value); setKind(e.target.value);
@@ -358,6 +356,15 @@ function ExperimentsInner() {
<div style={{ padding: 18 }}> <div style={{ padding: 18 }}>
<SkeletonLines n={6} /> <SkeletonLines n={6} />
</div> </div>
) : exps.length === 0 && (q || kind) ? (
/* 筛选无结果 ≠ 没有归档:文案必须区分,否则用户会以为归档全丢了 */
<Empty
icon="search"
title="没有匹配的归档"
hint={`筛选条件:${[q ? `关键词「${q}」` : "", kind ? `类型「${experimentKindLabel(kind)}」` : ""]
.filter(Boolean)
.join(" · ")};共 ${total} 条归档。清除筛选即可看到全部。`}
/>
) : exps.length === 0 ? ( ) : exps.length === 0 ? (
<Empty <Empty
icon="archive" icon="archive"
@@ -384,12 +391,15 @@ function ExperimentsInner() {
{exps.map((e) => ( {exps.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}> <tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
<td> <td>
<input {/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
type="checkbox" <label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
checked={picked.includes(e.id)} <input
onChange={() => togglePick(e.id)} type="checkbox"
aria-label={`选择 ${e.id} 用于对比`} checked={picked.includes(e.id)}
/> onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</label>
</td> </td>
<td className="cell-mono cell-strong">{e.id}</td> <td className="cell-mono cell-strong">{e.id}</td>
<td>{kindView(e.kind)}</td> <td>{kindView(e.kind)}</td>
+8 -6
View File
@@ -266,12 +266,14 @@ function FactorRow(props: {
<> <>
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}> <tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
<td onClick={(e) => e.stopPropagation()}> <td onClick={(e) => e.stopPropagation()}>
<input <label className="check check--cell" title={`选择因子 ${f.name}`}>
type="checkbox" <input
checked={props.checked} type="checkbox"
onChange={props.onToggleCheck} checked={props.checked}
aria-label={`选择因子 ${f.name}`} onChange={props.onToggleCheck}
/> aria-label={`选择因子 ${f.name}`}
/>
</label>
</td> </td>
<td className="cell-strong"> <td className="cell-strong">
<span className="mono">{f.name}</span> <span className="mono">{f.name}</span>
+183 -14
View File
@@ -51,6 +51,18 @@
--r-md: 12px; --r-md: 12px;
--r-lg: 16px; --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 节奏) */ /* 间距(4px 节奏) */
--sp-1: 4px; --sp-1: 4px;
--sp-2: 8px; --sp-2: 8px;
@@ -395,7 +407,10 @@ textarea {
background: var(--surface-3); background: var(--surface-3);
color: var(--text-1); color: var(--text-1);
border-radius: var(--r-sm); 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-size: var(--fs-sm);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -442,7 +457,8 @@ textarea {
} }
.btn--sm { .btn--sm {
padding: 4px 10px; height: var(--ctl-h-sm);
padding: 0 var(--ctl-px-sm);
font-size: var(--fs-xs); font-size: var(--fs-xs);
border-radius: var(--r-xs); border-radius: var(--r-xs);
} }
@@ -456,8 +472,8 @@ textarea {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 30px; width: var(--ctl-h-sm);
height: 30px; height: var(--ctl-h-sm);
border-radius: var(--r-sm); border-radius: var(--r-sm);
color: var(--text-2); color: var(--text-2);
background: transparent; background: transparent;
@@ -471,21 +487,39 @@ textarea {
color: var(--text-1); color: var(--text-1);
} }
/* 大号图标按钮:顶栏等需要更舒服点击目标的位置(仍属于 28/34/38 三级刻度) */
.icon-btn--lg {
width: var(--ctl-h-lg);
height: var(--ctl-h-lg);
}
/* ---------- 表单 ---------- */ /* ---------- 表单 ---------- */
.input, .input,
select.input, select.input,
textarea.input { textarea.input {
width: 100%; width: 100%;
/* 与 .btn 同一个高度令牌:输入框与按钮同排必然等高。
Chrome 的原生 date 输入比其它控件高 2px,靠 height 一并压平。 */
height: var(--ctl-h-md);
background: #0d1424; background: #0d1424;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
color: var(--text-1); color: var(--text-1);
border-radius: var(--r-sm); border-radius: var(--r-sm);
padding: 7px 10px; padding: 0 var(--ctl-px-sm);
font-size: var(--fs-sm); font-size: var(--fs-sm);
transition: border-color 0.15s ease, box-shadow 0.15s ease; 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 { .input:hover {
border-color: rgba(150, 165, 195, 0.42); border-color: rgba(150, 165, 195, 0.42);
} }
@@ -514,19 +548,53 @@ input[type="date"].input {
padding-right: 8px; padding-right: 8px;
} }
input[type="checkbox"] { /* 原生勾选控件:尺寸统一到 16px,并把「点击目标」交给外层 label(.check / .radio-row)——
width: 15px; 16px 的方块本身永远达不到 28px 触控目标,靠 label 撑开热区才是正确做法。 */
height: 15px; input[type="checkbox"],
input[type="radio"] {
width: 16px;
height: 16px;
margin: 0;
flex: none;
accent-color: var(--accent); accent-color: var(--accent);
cursor: pointer; 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="number"].input,
input[type="date"].input { input[type="date"].input {
width: auto; width: auto;
min-width: 0; min-width: 0;
} }
/* 数值/日期字段按内容给宽度:内容只有几位数字,拉满整行既难读也难与相邻控件对齐 */
.form-grid .field > input[type="number"].input {
max-width: 180px;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -539,11 +607,31 @@ input[type="date"].input {
font-size: var(--fs-xs); font-size: var(--fs-xs);
font-weight: 500; font-weight: 500;
letter-spacing: 0.2px; letter-spacing: 0.2px;
/* 无标签时也占一行高度(栅格里标签行高不一致会让控件上下错位) */
min-height: 17px;
line-height: 17px;
} }
.field__hint { .field__hint {
color: var(--text-3); color: var(--text-3);
font-size: var(--fs-xs); 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 { .form-grid {
@@ -553,9 +641,74 @@ input[type="date"].input {
align-items: end; align-items: end;
} }
/* 整行字段(长文本框、日期区间、条件编辑器) */
.field--wide {
grid-column: 1 / -1;
}
.form-grid .btn { .form-grid .btn {
align-self: end; 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 { .row {
@@ -995,6 +1148,9 @@ table.tbl {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
/* 历史记录 chip 是可点击控件:高度必须达到点击目标下限,
不能被 padding 压到 27px 这种「看得见点不准」的尺寸 */
min-height: var(--ctl-h-sm);
background: var(--surface-3); background: var(--surface-3);
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 999px; border-radius: 999px;
@@ -1002,6 +1158,13 @@ table.tbl {
font-size: var(--fs-xs); font-size: var(--fs-xs);
color: var(--text-2); color: var(--text-2);
} }
button.chip {
cursor: pointer;
}
button.chip:hover {
border-color: var(--accent);
color: var(--text-1);
}
.chip b { .chip b {
color: var(--text-1); color: var(--text-1);
@@ -1675,24 +1838,30 @@ table.tbl {
.radio-col { .radio-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: var(--sp-1);
} }
.radio-row { .radio-row {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 7px; gap: var(--sp-2);
font-size: 13px; /* 点击目标由整行承担(16px 的圆点本身太小),并与 .check 同一高度节奏 */
min-height: var(--ctl-h-sm);
padding: 2px 0;
font-size: var(--fs-sm);
color: var(--text-2); color: var(--text-2);
cursor: pointer; cursor: pointer;
line-height: 1.45; line-height: 1.45;
} }
.radio-row input { .radio-row input {
margin-top: 2px;
flex: none; flex: none;
} }
.radio-row:hover { .radio-row:hover {
color: var(--text-1); color: var(--text-1);
} }
/* 单选行里的说明文字:与选项标题同一行时用弱化色,不抢视线 */
.radio-row .hint {
color: var(--text-3);
}
/* ---------- 归档详情:元数据条 / KV / 规格表 / 代码块 ---------- */ /* ---------- 归档详情:元数据条 / KV / 规格表 / 代码块 ---------- */
+142 -101
View File
@@ -207,8 +207,8 @@ export default function SelectionPage() {
<Field label="选股时点 as_of" hint="留空 = 最近可用交易日"> <Field label="选股时点 as_of" hint="留空 = 最近可用交易日">
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} /> <input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
</Field> </Field>
<Field label="标的范围"> <Field label="标的范围" hint="ST 按当时名称判定">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}> <label className="check">
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} /> <input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST 剔除 ST
</label> </label>
@@ -217,120 +217,161 @@ export default function SelectionPage() {
{mode === "score" ? ( {mode === "score" ? (
<> <>
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>评分因子与权重</div> <div className="form-section__title" style={{ margin: "12px 0 8px" }}>
{factorRows.map((r, i) => ( 评分因子与权重
<div className="row" style={{ gap: 8, marginBottom: 8 }} key={i}> </div>
<select {/* 与回测页同一套「表头 + 栅格行」:列宽由 CSS 决定,不再用 flex 拉伸
className="input" (flex:1 会把权重框拉到几百像素宽,与一位小数的内容完全不匹配) */}
style={{ flex: 2 }} <div className="factor-rows">
value={r.name} <div className="factor-rows__head" aria-hidden="true">
onChange={(e) => { <span>因子</span>
const next = [...factorRows]; <span>权重</span>
next[i] = { ...next[i], name: e.target.value }; <span />
setFactorRows(next); </div>
}} {factorRows.map((r, i) => (
> <div className="factor-row" key={i}>
{factors.map((f) => ( <select
<option key={f.name} value={f.name}>{f.name}</option> className="input"
))} aria-label={`第 ${i + 1} 个因子的名称`}
</select> 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 <input
className="input" className="input"
style={{ flex: 1 }}
type="number" type="number"
step="0.1" inputMode="numeric"
value={r.weight} min={1}
onChange={(e) => { value={topN}
const next = [...factorRows]; onChange={(e) => setTopN(Number(e.target.value))}
next[i] = { ...next[i], weight: Number(e.target.value) };
setFactorRows(next);
}}
/> />
<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> </Field>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div style={{ fontSize: 13, color: "var(--text-2)", margin: "10px 0 6px" }}>条件(全部满足才入选)</div> <div className="form-section__title" style={{ margin: "12px 0 8px" }}>
{conds.map((c, i) => { 条件(全部满足才入选)
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field)); </div>
const isRef = c.field === "close" && c.ref === "ma60"; <div className="cond-rows">
return ( <div className="cond-rows__head" aria-hidden="true">
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }} key={i}> <span>字段</span>
<select <span>比较</span>
className="input" <span>取值</span>
style={{ flex: 2 }} <span />
value={isRef ? "close_ma60_ref" : c.field} </div>
onChange={(e) => { {conds.map((c, i) => {
const next = [...conds]; const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
const v = e.target.value; const isRef = c.field === "close" && c.ref === "ma60";
next[i] = v === "close_ma60_ref" return (
? { field: "close", op: "gt", ref: "ma60", value: null } <div className="cond-row" key={i}>
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 }; <select
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
className="input" className="input"
style={{ flex: 1 }} aria-label={`第 ${i + 1} 个条件的字段`}
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"} value={isRef ? "close_ma60_ref" : c.field}
value={String(c.value ?? "")}
onChange={(e) => { onChange={(e) => {
const next = [...conds]; const next = [...conds];
const raw = e.target.value; const v = e.target.value;
const val = c.op === "in" next[i] = v === "close_ma60_ref"
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean) ? { field: "close", op: "gt", ref: "ma60", value: null }
: (meta?.kind === "num" && !c.field.startsWith("static.") : { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
? Number(raw)
: raw);
next[i] = { ...next[i], value: val as never };
setConds(next); 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} </div>
<button );
className="btn" })}
disabled={conds.length <= 1} </div>
onClick={() => setConds(conds.filter((_, j) => j !== i))} <div style={{ marginTop: 10 }}>
> <Btn icon="layers" size="sm" onClick={() => setConds([...conds, emptyCondition()])}>
删除 添加条件
</button> </Btn>
</div> </div>
);
})}
<button className="btn" onClick={() => setConds([...conds, emptyCondition()])}>+ 条件</button>
</> </>
)} )}
+7 -2
View File
@@ -50,6 +50,8 @@ export default function StrategiesPage() {
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [editing, setEditing] = useState<StrategyParams | null>(null); const [editing, setEditing] = useState<StrategyParams | null>(null);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
// 保存被拦下过 → 立即展开全部校验错误(见 StrategyParamsFormProps.revealErrors)
const [revealErrors, setRevealErrors] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [docId, setDocId] = useState<string | null>(null); const [docId, setDocId] = useState<string | null>(null);
@@ -101,6 +103,7 @@ export default function StrategiesPage() {
setDocId(null); setDocId(null);
setMsg(""); setMsg("");
setErr(""); setErr("");
setRevealErrors(false);
} }
function startEdit(s: StrategyDefinition) { function startEdit(s: StrategyDefinition) {
@@ -109,6 +112,7 @@ export default function StrategiesPage() {
setDocId(s.id ?? null); setDocId(s.id ?? null);
setMsg(""); setMsg("");
setErr(""); setErr("");
setRevealErrors(false);
} }
async function save() { async function save() {
@@ -116,6 +120,7 @@ export default function StrategiesPage() {
const errors = validateParams(editing, { requireMeta: true }); const errors = validateParams(editing, { requireMeta: true });
if (Object.keys(errors).length) { if (Object.keys(errors).length) {
setErr(Object.values(errors)[0]); setErr(Object.values(errors)[0]);
setRevealErrors(true); // 一次把问题全列出来,而不是让用户逐个试
return; return;
} }
setBusy(true); setBusy(true);
@@ -199,6 +204,7 @@ export default function StrategiesPage() {
showPeriod={false} showPeriod={false}
disabled={busy} disabled={busy}
errors={validateParams(editing, { requireMeta: true })} errors={validateParams(editing, { requireMeta: true })}
revealErrors={revealErrors}
/> />
<div className="hint" style={{ marginTop: 10 }}> <div className="hint" style={{ marginTop: 10 }}>
策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** —— 策略只保存「怎么选股/怎么调仓/怎么收费」,**不含回测区间与初始资金** ——
@@ -234,8 +240,7 @@ export default function StrategiesPage() {
title={`已保存策略${list ? ` · ${list.length}` : ""}`} title={`已保存策略${list ? ` · ${list.length}` : ""}`}
tools={ tools={
<input <input
className="input" className="input input--search"
style={{ width: 220 }}
placeholder="搜索策略名 / 说明 / 因子 / 条件" placeholder="搜索策略名 / 说明 / 因子 / 条件"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
@@ -223,8 +223,7 @@ export function BacktestResultView({
tools={ tools={
<div className="row" style={{ gap: 8 }}> <div className="row" style={{ gap: 8 }}>
<input <input
className="input" className="input input--search"
style={{ width: 180 }}
placeholder="搜索代码或名称" placeholder="搜索代码或名称"
value={curveQuery} value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)} onChange={(e) => setCurveQuery(e.target.value)}
@@ -240,8 +239,8 @@ export function BacktestResultView({
<> <>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}> <div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select <select
className="input" className="input input--picker"
style={{ width: 280 }} aria-label="选择要查看的个股"
value={activeCurve?.symbol ?? ""} value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)} onChange={(e) => setActive(e.target.value)}
> >
+159 -63
View File
@@ -10,6 +10,7 @@
* *
* 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。 * 组件**不持有业务状态**:value/onChange 由父组件控制,父组件负责提交与落库。
*/ */
import { useId, useState } from "react";
import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types"; import type { FactorMeta, ResearchCondition, ResearchSpec, StrategyDefinition } from "@/lib/types";
import { Btn, Field } from "@/components/ui"; import { Btn, Field } from "@/components/ui";
@@ -274,6 +275,14 @@ export interface StrategyParamsFormProps {
errors?: Record<string, string>; errors?: Record<string, string>;
/** 因子选择是否允许加权多项(默认允许) */ /** 因子选择是否允许加权多项(默认允许) */
multiFactor?: boolean; multiFactor?: boolean;
/**
* 是否立即显示全部校验错误。
*
* 默认 false:只在**字段失焦过**之后才显示该字段的错误。理由:受控输入在用户
* 清空内容准备重填的瞬间就会被判为「至少为 1」,立刻标红属于打扰式提示;
* 提交被拦下时父组件把本值设为 true,确保此时所有问题一次看清。
*/
revealErrors?: boolean;
} }
export function StrategyParamsForm({ export function StrategyParamsForm({
@@ -285,7 +294,15 @@ export function StrategyParamsForm({
disabled = false, disabled = false,
errors = {}, errors = {},
multiFactor = true, multiFactor = true,
revealErrors = false,
}: StrategyParamsFormProps) { }: 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 set = (patch: Partial<StrategyParams>) => onChange({ ...p, ...patch });
const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) => const setFactor = (i: number, patch: Partial<{ name: string; weight: number }>) =>
set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) }); set({ factors: p.factors.map((f, j) => (j === i ? { ...f, ...patch } : f)) });
@@ -321,21 +338,25 @@ export function StrategyParamsForm({
disabled={disabled} disabled={disabled}
onChange={(e) => set({ description: e.target.value })} onChange={(e) => set({ description: e.target.value })}
/> />
{errors.description && ( {showErr("description") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.description} {showErr("description")}
</div> </div>
)} )}
</Field> </Field>
</div> </div>
)} )}
{/* ---------- 因子(打分公式) ---------- */} {/* ---------- 因子(打分公式) ----------
用「表头 + 栅格行」表达多项因子:列宽由 CSS 栅格统一控制(不再在 JSX 里写
内联宽度),这样因子下拉、权重输入、删除按钮在所有行严格成列对齐;
视觉表头给正常用户,aria-label 给读屏(重复行只有表头时读屏无法分辨)。 */}
<div className="between" style={{ marginBottom: 8 }}> <div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>打分因子(score = Σ 权重 × 因子值,越大越优先)</b> <b className="form-section__title">打分因子(score = Σ 权重 × 因子值,越大越优先)</b>
{multiFactor && ( {multiFactor && (
<Btn <Btn
icon="layers" icon="layers"
size="sm"
disabled={disabled} disabled={disabled}
onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })} onClick={() => set({ factors: [...p.factors, { name: factorOptions[0]?.name ?? "", weight: 1 }] })}
> >
@@ -343,12 +364,18 @@ export function StrategyParamsForm({
</Btn> </Btn>
)} )}
</div> </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) => ( {p.factors.map((f, i) => (
<div className="row" key={i} style={{ gap: 6 }}> <div className="factor-row" key={i}>
<select <select
id={`${fid}-name-${i}`}
className="input" className="input"
style={{ width: 220 }} aria-label={`第 ${i + 1} 个因子的名称`}
value={f.name} value={f.name}
disabled={disabled} disabled={disabled}
onChange={(e) => setFactor(i, { name: e.target.value })} onChange={(e) => setFactor(i, { name: e.target.value })}
@@ -363,27 +390,37 @@ export function StrategyParamsForm({
))} ))}
</select> </select>
<input <input
id={`${fid}-weight-${i}`}
className="input mono" className="input mono"
style={{ width: 84 }}
type="number" type="number"
inputMode="decimal"
step="0.1" step="0.1"
min={0}
aria-label={`第 ${i + 1} 个因子的权重`}
title="权重:越大表示该因子在打分中越重要(1 = 等权)"
value={f.weight} value={f.weight}
disabled={disabled} disabled={disabled}
title="权重"
onChange={(e) => setFactor(i, { weight: Number(e.target.value) })} onChange={(e) => setFactor(i, { weight: Number(e.target.value) })}
/> />
{multiFactor && p.factors.length > 1 && ( {multiFactor && p.factors.length > 1 ? (
/* 与同行的下拉/输入同为 md 高度:行内控件必须等高,否则整行看起来是斜的 */
<Btn <Btn
icon="x" icon="x"
disabled={disabled} disabled={disabled}
aria-label={`删除第 ${i + 1} 个因子`}
title="删除该因子"
onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })} onClick={() => set({ factors: p.factors.filter((_, j) => j !== i) })}
> />
删除 ) : (
</Btn> <span />
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="hint" style={{ marginTop: 6 }}>
权重 1 = 等权;只想用单个因子时把其它因子删掉即可。多个因子会先各自做横截面
z-score 标准化再加权,避免量纲不同互相压制。
</div>
{errors.factors && ( {errors.factors && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.factors} {errors.factors}
@@ -392,66 +429,82 @@ export function StrategyParamsForm({
{/* ---------- 选股规模与周期 ---------- */} {/* ---------- 选股规模与周期 ---------- */}
<div className="form-grid" style={{ marginTop: 12 }}> <div className="form-grid" style={{ marginTop: 12 }}>
<Field label="候选池 n(择股条件选出股数)"> <Field
label="候选池 n(择股条件选出股数)"
hint="范围 1~500;n 越大越分散,越小越集中"
>
<input <input
className="input" className={`input${errors.topN ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="numeric"
min={1} min={1}
max={500} max={500}
value={p.topN} value={p.topN}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ topN: Number(e.target.value) })} onChange={(e) => set({ topN: Number(e.target.value) })}
onBlur={blur("topN")}
/> />
{showErr("topN") && (
<div className="field-err" role="alert">
{showErr("topN")}
</div>
)}
</Field> </Field>
<Field label="持仓数 x(≤ n)"> <Field label="持仓数 x(≤ n)" hint="最终实际持有只数,通常等于 n">
<input <input
className={`input${errors.holdX ? " input--invalid" : ""}`} className={`input${errors.holdX ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="numeric"
min={1} min={1}
max={p.topN} max={p.topN}
value={p.holdX} value={p.holdX}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ holdX: Number(e.target.value) })} onChange={(e) => set({ holdX: Number(e.target.value) })}
onBlur={blur("holdX")}
/> />
{errors.holdX && ( {showErr("holdX") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.holdX} {showErr("holdX")}
</div> </div>
)} )}
</Field> </Field>
<Field label="择股间隔 m(月,0=跟随 y)" hint="m:多久重新挑一次股"> <Field label="择股间隔 m(月)" hint="多久重新挑一次股;0 = 跟随 y">
<input <input
className="input" className={`input${errors.mMonths ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="numeric"
min={0} min={0}
max={60} max={60}
value={p.mMonths} value={p.mMonths}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ mMonths: Number(e.target.value) })} onChange={(e) => set({ mMonths: Number(e.target.value) })}
onBlur={blur("mMonths")}
/> />
{errors.mMonths && ( {showErr("mMonths") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.mMonths} {showErr("mMonths")}
</div> </div>
)} )}
</Field> </Field>
<Field label="调仓间隔 y(月,0=跟随 m)" hint="y:多久按最新选股结果换一次仓"> <Field label="调仓间隔 y(月)" hint="多久按最新选股结果换一次仓;0 = 跟随 m">
<input <input
className="input" className={`input${errors.yMonths ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="numeric"
min={0} min={0}
max={60} max={60}
value={p.yMonths} value={p.yMonths}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ yMonths: Number(e.target.value) })} onChange={(e) => set({ yMonths: Number(e.target.value) })}
onBlur={blur("yMonths")}
/> />
{errors.yMonths && ( {showErr("yMonths") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.yMonths} {showErr("yMonths")}
</div> </div>
)} )}
</Field> </Field>
<Field label="调仓频率(m、y 均为 0 时生效)"> <Field label="调仓频率" hint="仅当 m、y 都为 0 时生效(否则由 m/y 决定)">
<select <select
className="input" className="input"
value={p.rebalance} value={p.rebalance}
@@ -465,6 +518,7 @@ export function StrategyParamsForm({
<Field <Field
label="买不进时的补位(涨停 / 停牌)" label="买不进时的补位(涨停 / 停牌)"
hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合" hint="后端两个字段互斥,这里用三选一表达,避免出现「都不生效」的静默组合"
className="field--wide"
> >
<div className="radio-col"> <div className="radio-col">
{( {(
@@ -474,10 +528,12 @@ export function StrategyParamsForm({
["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"], ["none", "不补位", "买不进就空着,实际持仓可能少于持仓数 x"],
] as const ] as const
).map(([val, label, desc]) => ( ).map(([val, label, desc]) => (
<label key={val} className="radio-row" title={desc}> <label key={val} className="radio-row">
<input <input
type="radio" type="radio"
name="fill-policy" /* 同一页面可能挂两份表单:name 必须带表单实例前缀,
否则两边的单选会互相取消选中(原生 radio 按 name 分组) */
name={`${fid}-fill-policy`}
checked={p.fillPolicy === val} checked={p.fillPolicy === val}
disabled={disabled} disabled={disabled}
onChange={() => set({ fillPolicy: val })} onChange={() => set({ fillPolicy: val })}
@@ -506,21 +562,22 @@ export function StrategyParamsForm({
<option value="none">不复权 none</option> <option value="none">不复权 none</option>
</select> </select>
</Field> </Field>
<Field label="标的范围"> <Field label="标的范围" hint="ST 按择股日当时的股票名称判定">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}> <label className="check">
<input <input
type="checkbox" type="checkbox"
checked={p.excludeSt} checked={p.excludeSt}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ excludeSt: e.target.checked })} onChange={(e) => set({ excludeSt: e.target.checked })}
/> />
剔除 ST(按择股日当时名称判定) 剔除 ST
</label> </label>
</Field> </Field>
<Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制"> <Field label="最少上市天数" hint="避免次新股噪声;0 = 不限制">
<input <input
className="input" className="input"
type="number" type="number"
inputMode="numeric"
min={0} min={0}
step={10} step={10}
value={p.minListingDays} value={p.minListingDays}
@@ -528,86 +585,113 @@ export function StrategyParamsForm({
onChange={(e) => set({ minListingDays: Number(e.target.value) })} onChange={(e) => set({ minListingDays: Number(e.target.value) })}
/> />
</Field> </Field>
<Field label="手续费率 %"> <Field
label="手续费率 %"
hint="买卖双向收取;A 股常见 0.025%~0.03%"
>
<input <input
className="input" className={`input${errors.costs ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="decimal"
step="0.01" step="0.01"
min={0} min={0}
max={1}
value={p.commission} value={p.commission}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ commission: Number(e.target.value) })} onChange={(e) => set({ commission: Number(e.target.value) })}
/> />
</Field> </Field>
<Field label="印花税率 %" hint="仅卖出收取"> <Field label="印花税率 %" hint="仅卖出收取;A 股 0.05%(2023 年 8 月起减半)">
<input <input
className="input" className={`input${errors.costs ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="decimal"
step="0.01" step="0.01"
min={0} min={0}
max={1}
value={p.stamp} value={p.stamp}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ stamp: Number(e.target.value) })} onChange={(e) => set({ stamp: Number(e.target.value) })}
/> />
</Field> </Field>
<Field label="滑点率 %"> <Field label="滑点率 %" hint="按成交价的比例估计冲击成本;0.05%~0.1% 较常见">
<input <input
className="input" className={`input${errors.costs ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="decimal"
step="0.01" step="0.01"
min={0} min={0}
max={1}
value={p.slippage} value={p.slippage}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ slippage: Number(e.target.value) })} onChange={(e) => set({ slippage: Number(e.target.value) })}
/> />
</Field> </Field>
<Field label="最低佣金(元/笔)"> <Field label="最低佣金(元/笔)" hint="按笔收取的下限,如 5 元">
<input <input
className="input" className={`input${errors.minCommission ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="decimal"
step="1" step="1"
min={0} min={0}
value={p.minCommission} value={p.minCommission}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ minCommission: Number(e.target.value) })} onChange={(e) => set({ minCommission: Number(e.target.value) })}
onBlur={blur("minCommission")}
/> />
{errors.minCommission && ( {showErr("minCommission") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.minCommission} {showErr("minCommission")}
</div> </div>
)} )}
</Field> </Field>
{/* 费率类错误的行内落点:三个费率共用一个错误键(费率不能为负) */}
{errors.costs && (
<div className="field-err" role="alert" style={{ gridColumn: "1 / -1" }}>
{errors.costs}
</div>
)}
{showPeriod && ( {showPeriod && (
<> <>
<Field label="初始资金(元)"> <Field label="初始资金(元)" hint="回测初始本金,默认 100 万">
<input <input
className="input" className={`input${errors.capital ? " input--invalid" : ""}`}
type="number" type="number"
inputMode="numeric"
step={100000} step={100000}
min={10000} min={10000}
value={p.capital} value={p.capital}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ capital: Number(e.target.value) })} onChange={(e) => set({ capital: Number(e.target.value) })}
onBlur={blur("capital")}
/> />
{errors.capital && ( {showErr("capital") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.capital} {showErr("capital")}
</div> </div>
)} )}
</Field> </Field>
<Field label="开始日期"> <Field
label="开始日期"
hint="回测起始日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input <input
type="date" type="date"
className="input" className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.start} value={p.start}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ start: e.target.value })} onChange={(e) => set({ start: e.target.value })}
/> />
</Field> </Field>
<Field label="结束日期"> <Field
label="结束日期"
hint="回测结束日(含)"
className={showErr("period") ? "field--invalid" : undefined}
>
<input <input
type="date" type="date"
className="input" className={`input${showErr("period") ? " input--invalid" : ""}`}
value={p.end} value={p.end}
disabled={disabled} disabled={disabled}
onChange={(e) => set({ end: e.target.value })} onChange={(e) => set({ end: e.target.value })}
@@ -616,18 +700,21 @@ export function StrategyParamsForm({
</> </>
)} )}
</div> </div>
{errors.period && ( {showErr("period") && (
<div className="field-err" role="alert"> <div className="field-err" role="alert">
{errors.period} {showErr("period")}
</div> </div>
)} )}
{/* ---------- 选股过滤条件 ---------- */} {/* ---------- 选股过滤条件 ---------- */}
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div className="between" style={{ marginBottom: 8 }}> <div className="between" style={{ marginBottom: 8 }}>
<b style={{ fontSize: 13 }}>选股过滤条件(AND,universe 之后、因子排序之前执行)</b> <b className="form-section__title">
选股过滤条件(AND,universe 之后、因子排序之前执行)
</b>
<Btn <Btn
icon="layers" icon="layers"
size="sm"
disabled={disabled} disabled={disabled}
onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })} onClick={() => set({ conditions: [...p.conditions, { field: "", op: "gte", value: 0 }] })}
> >
@@ -637,12 +724,19 @@ export function StrategyParamsForm({
{p.conditions.length === 0 ? ( {p.conditions.length === 0 ? (
<div className="hint">未设置条件:候选池 = universe 内因子分最高的 n 只。</div> <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) => ( {p.conditions.map((c, i) => (
<div className="row" key={i} style={{ gap: 6 }}> <div className="cond-row" key={i}>
<input <input
className="input" className="input mono"
style={{ width: 190 }} list={`${fid}-cond-fields`}
aria-label={`第 ${i + 1} 个条件的字段名`}
placeholder="字段(dv_ratio / pe / ma60 …)" placeholder="字段(dv_ratio / pe / ma60 …)"
value={c.field} value={c.field}
disabled={disabled} disabled={disabled}
@@ -656,7 +750,7 @@ export function StrategyParamsForm({
/> />
<select <select
className="input" className="input"
style={{ width: 76 }} aria-label={`第 ${i + 1} 个条件的比较符`}
value={c.op} value={c.op}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange={(e) =>
@@ -674,8 +768,10 @@ export function StrategyParamsForm({
))} ))}
</select> </select>
<input <input
className="input" className="input mono"
style={{ width: 110 }} aria-label={`第 ${i + 1} 个条件的取值`}
inputMode="decimal"
placeholder="数值或字段名"
value={String(c.value ?? "")} value={String(c.value ?? "")}
disabled={disabled} disabled={disabled}
onChange={(e) => { onChange={(e) => {
@@ -693,10 +789,10 @@ export function StrategyParamsForm({
<Btn <Btn
icon="x" icon="x"
disabled={disabled} disabled={disabled}
aria-label={`删除第 ${i + 1} 个条件`}
title="删除该条件"
onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })} onClick={() => set({ conditions: p.conditions.filter((_, j) => j !== i) })}
> />
删除
</Btn>
</div> </div>
))} ))}
</div> </div>
+1 -2
View File
@@ -131,11 +131,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="topbar"> <div className="topbar">
<button <button
type="button" type="button"
className="icon-btn" className="icon-btn icon-btn--lg"
aria-label="打开导航菜单" aria-label="打开导航菜单"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
style={{ width: 36, height: 36 }}
> >
<Icon name="filter" size={18} /> <Icon name="filter" size={18} />
</button> </button>
+22 -1
View File
@@ -1,3 +1,24 @@
# scripts # scripts
数据同步 / 运维 / 一次性脚本(如 Tushare 拉数 CLI、Parquet 导出、Qlib Dataset 构建)。 数据同步 / 运维 / 自检脚本。
## 自检脚本(改完对应部分就跑一遍)
| 脚本 | 检查什么 | 用法 |
|------|----------|------|
| `verify_strategy_workspace.py` | 策略库 / 回测 / 归档链路的端到端契约(含按 kind 逐类验证归档页) | `python3 scripts/verify_strategy_workspace.py`(`--skip-job` 跳过真实回测) |
| `verify_backtest_page_contract.py` | 回测页与后端字段契约(净值点数、个股曲线数、收益口径) | `python3 scripts/verify_backtest_page_contract.py` |
| `verify_ui_alignment.py` | **UI 对齐与控件一致性**:同排控件等高、尺寸归一、点击目标、标签与无障碍名、圆角/字号一致、无横向溢出、提示不裁切 | `python3 scripts/verify_ui_alignment.py`(`--dump` 打印几何明细,`--width 375` 只测小屏) |
| `qlib_verify.py` | pyqlib 安装与数据落盘基线 | `cd backend && uv run python ../scripts/qlib_verify.py` |
`verify_ui_alignment.py` 用**系统 Chrome + 原生 CDP**(仅标准库实现,不依赖 playwright),
独占随机端口与临时 profile,不会干扰你正在用的浏览器。
## 数据 / 运维
| 脚本 | 用途 |
|------|------|
| `run_dividend_case.py` | 复现高股息案例(全市场股息率 TopN、每 m 月择股) |
| `migrate_sqlite_to_mysql.py` | 历史 SQLite 数据迁移到本机 MariaDB |
| `grant_local_mysql_user.sql` / `mysql_recover_and_grants.sh` | 本机 MariaDB 账号与权限 |
| `dev.sh` | 一键起后端 + 前端(开发用) |
+1 -1
View File
@@ -44,7 +44,7 @@ def main() -> None:
"\n".join(f"{s}\t{days[0].strftime('%Y-%m-%d')}\t{days[-1].strftime('%Y-%m-%d')}" for s in SYMBOLS), "\n".join(f"{s}\t{days[0].strftime('%Y-%m-%d')}\t{days[-1].strftime('%Y-%m-%d')}" for s in SYMBOLS),
encoding="utf-8", encoding="utf-8",
) )
rng = np.random.default_rng(7) # 合成行情用解析式生成(无随机项),不需要随机数发生器
for k, sym in enumerate(SYMBOLS): for k, sym in enumerate(SYMBOLS):
(tmp / "features" / sym).mkdir(parents=True) (tmp / "features" / sym).mkdir(parents=True)
drift = 0.002 + 0.001 * k drift = 0.002 + 0.001 * k
+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""UI 对齐与控件一致性自检(无头 Chrome + 原生 CDP,仅用标准库)。
为什么需要这个脚本
------------------
「控件没对齐 / 尺寸和内容不匹配 / 输入不友好」这类问题**肉眼能看出,但无法回归**:
截图不能进版本库比较,人工点页面又会漏。所以把「对齐」变成可测量的不变量,
每次改样式后跑一遍,用数字证明没有退化。
为什么自己实现 CDP
------------------
不依赖 playwright / selenium / websocket-client(本项目零额外依赖原则)。
只用标准库 socket 做 WebSocket 握手与帧编解码,连本机 Chrome 的调试端口;
用**独占端口 + 临时 profile**,不碰用户正在用的浏览器(共享端口曾导致误开标签页)。
检查项(每条都对应一种真实可感知的缺陷)
----------------------------------------
1. 同排等高 同一横向视觉行内的输入框/下拉/按钮高度必须一致(容差 1.5px)
2. 高度归一 单页控件高度取值 ≤ 3 种(输入 / 标准按钮 / 小按钮),
杜绝「每个组件各写一套 padding」导致的 33/34/35px 参差
3. 点击目标 ≥ 28px 高;图标按钮 ≥ 24×24
4. 标签可见 每个 input/select/textarea 都有可见标签或 aria-label
(placeholder 不能当唯一标签)
5. 图标按钮命名 仅图标的按钮必须有 aria-label / title(否则读屏只会念「按钮」)
6. 字号 / 圆角一致 控件字号取值 ≤ 3 种、圆角取值 ≤ 2 种
7. 尺寸匹配内容 number/date 输入不得占满整行、文本输入不得窄于 120px
8. 无横向溢出 1500px 与 375px 两种视口下页面都不得出现横向滚动
9. 提示不裁切 字段提示/错误文本不得溢出或裁切
用法
----
python3 scripts/verify_ui_alignment.py # 检查默认页面
python3 scripts/verify_ui_alignment.py --dump # 额外打印几何明细
python3 scripts/verify_ui_alignment.py --pages /experiments
python3 scripts/verify_ui_alignment.py --width 375 # 小屏复核
"""
from __future__ import annotations
import argparse
import base64
import contextlib
import glob
import json
import os
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
WEB = os.environ.get("QLIB_WEB", "http://127.0.0.1:3000")
DEFAULT_PAGES = ["/", "/backtest", "/strategies", "/selection", "/experiments", "/stocks", "/factors"]
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
]
CHROME_GLOBS = [
os.path.expanduser(
"~/Library/Caches/ms-playwright/chromium-*/chrome-mac*/"
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
),
os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux/chrome"),
]
# ---------------------------------------------------------------- CDP(标准库)
class _WS:
"""最小 WebSocket 客户端(RFC6455 文本帧、客户端掩码、ping 自动回 pong)。"""
def __init__(self, url: str, timeout: float = 60.0) -> None:
u = urllib.parse.urlparse(url)
self.sock = socket.create_connection((u.hostname, u.port), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
path = u.path + (f"?{u.query}" if u.query else "")
req = (
f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(req.encode())
self._buf = b""
while b"\r\n\r\n" not in self._buf:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 握手失败:连接被关闭")
self._buf += chunk
head, self._buf = self._buf.split(b"\r\n\r\n", 1)
if b" 101 " not in head.split(b"\r\n")[0]:
raise RuntimeError(f"CDP 握手失败:{head.split(b'\r\n')[0]!r}")
def _read(self, n: int) -> bytes:
while len(self._buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 连接已关闭")
self._buf += chunk
out, self._buf = self._buf[:n], self._buf[n:]
return out
def _frame(self, opcode: int, payload: bytes) -> bytes:
head = bytearray([0x80 | opcode])
n = len(payload)
if n < 126:
head.append(0x80 | n)
elif n < 65536:
head.append(0x80 | 126)
head += struct.pack(">H", n)
else:
head.append(0x80 | 127)
head += struct.pack(">Q", n)
mask = os.urandom(4)
head += mask
return bytes(head) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
def send_text(self, text: str) -> None:
self.sock.sendall(self._frame(0x1, text.encode()))
def recv_text(self) -> str:
while True:
b1, b2 = self._read(2)
opcode, n, masked = b1 & 0x0F, b2 & 0x7F, bool(b2 & 0x80)
if n == 126:
n = struct.unpack(">H", self._read(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._read(8))[0]
mask = self._read(4) if masked else None
data = self._read(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
if opcode == 0x9: # ping
self.sock.sendall(self._frame(0xA, data))
continue
if opcode == 0x8:
raise RuntimeError("CDP 连接被对端关闭")
if opcode in (0x1, 0x2):
return data.decode("utf-8", "ignore")
def close(self) -> None:
with contextlib.suppress(OSError):
self.sock.close()
class CDP:
def __init__(self, ws_url: str) -> None:
self.ws = _WS(ws_url)
self._id = 0
def call(self, method: str, params: dict | None = None, timeout: float = 90.0):
self._id += 1
mid = self._id
self.ws.send_text(json.dumps({"id": mid, "method": method, "params": params or {}}))
deadline = time.time() + timeout
while time.time() < deadline:
msg = json.loads(self.ws.recv_text())
if msg.get("id") == mid:
if "error" in msg:
raise RuntimeError(f"{method} 失败:{msg['error']}")
return msg.get("result", {})
raise TimeoutError(f"{method} 超时")
def find_chrome() -> str | None:
for p in CHROME_CANDIDATES:
if os.path.exists(p):
return p
for pat in CHROME_GLOBS:
hit = sorted(glob.glob(pat))
if hit:
return hit[-1]
return shutil.which("google-chrome") or shutil.which("chromium")
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
# ---------------------------------------------------------------- 页面几何探针
PROBE = r"""
(() => {
const rect = (el) => { const b = el.getBoundingClientRect();
return { x: +b.x.toFixed(1), y: +b.y.toFixed(1), w: +b.width.toFixed(1), h: +b.height.toFixed(1) }; };
const txt = (s) => (s || "").replace(/\s+/g, " ").trim();
const css = (el) => getComputedStyle(el);
const nameOf = (el) => txt(el.getAttribute("aria-label") || el.getAttribute("title") ||
el.innerText || el.value || el.placeholder || el.getAttribute("name") || el.tagName);
const ctrlSel = "input:not([type=hidden]), select, textarea, button, a.btn, .btn, .icon-btn";
const controls = [...document.querySelectorAll(ctrlSel)].filter((e) => {
const r = e.getBoundingClientRect();
const s = css(e);
return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
}).map((e) => {
const r = rect(e), s = css(e);
// 搜索框/字段内嵌的图标按钮:天然比输入框小且垂直居中,不参与「同排等高」
const nested = !!e.closest(".searchbox, .field, .input-group");
return { tag: e.tagName.toLowerCase(), type: e.getAttribute("type") || "",
cls: txt(e.className).slice(0, 40), label: nameOf(e).slice(0, 30),
aria: !!(e.getAttribute("aria-label") || e.getAttribute("title")),
iconOnlyBtn: e.tagName === "BUTTON" && !txt(e.innerText) && !e.getAttribute("data-label"),
nestedIcon: nested && e.classList.contains("icon-btn"),
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),
placeholder: txt(e.getAttribute("placeholder")).slice(0, 30),
hasVisibleLabel: (() => {
const id = e.getAttribute("id");
if (id && document.querySelector(`label[for="${id}"]`)) return true;
if (e.closest("label")) return true;
const f = e.closest(".field, .form-field, .input-group");
if (f && f.querySelector(".field__label, label")) return true;
return false;
})(),
required: e.hasAttribute("required"), disabled: e.hasAttribute("disabled") || e.disabled === true,
// 勾选/单选控件的真实点击目标是外层 label(.check / .radio-row),
// 16px 的方块永远达不到 28px;量 label 才是量「用户能不能点到」。
hitH: (() => {
if (e.type !== "checkbox" && e.type !== "radio") return r.h;
const lab = e.closest("label");
return lab ? +lab.getBoundingClientRect().height.toFixed(1) : r.h;
})(),
// pill / chip 是刻意的胶囊形控件,不参与圆角统一
intentionalPill: !!e.closest(".pill, .chip, .tabs, .seg"),
// 多行选项行(.radio-row:标题 + 说明会换行):高度由文案决定,
// 属于「选项热区」而不是「尺寸令牌控件」,不参与高度归一比较
optionRow: !!e.closest(".radio-row") };
});
// 横向溢出(含被压出的子元素)
const overflows = [];
for (const el of document.querySelectorAll("body *")) {
const s = css(el);
if (s.display === "none" || s.visibility === "hidden" || s.position === "fixed") continue;
if (el.scrollWidth > el.clientWidth + 2 && el.clientWidth > 0 && s.overflowX !== "auto" && s.overflowX !== "scroll") {
const tag = el.tagName.toLowerCase() + (el.className && typeof el.className === "string" ? "." + txt(el.className).split(" ")[0] : "");
overflows.push({ el: tag, scrollW: el.scrollWidth, clientW: el.clientWidth, text: txt(el.innerText).slice(0, 40) });
}
if (overflows.length > 25) break;
}
// 提示 / 错误文本是否被裁切
const clipped = [];
for (const el of document.querySelectorAll(".field__hint, .hint, .field__error, .err")) {
const r = el.getBoundingClientRect(), s = css(el);
if (r.width === 0) continue;
if (el.scrollHeight > el.clientHeight + 2 || (s.textOverflow === "ellipsis" && el.scrollWidth > el.clientWidth + 2)) {
clipped.push({ el: txt(el.className).split(" ")[0], text: txt(el.innerText).slice(0, 40),
scrollH: el.scrollHeight, clientH: el.clientHeight });
}
}
return {
path: location.pathname, viewport: { w: innerWidth, h: innerHeight },
docScrollW: document.documentElement.scrollWidth,
bodyScrollW: document.body.scrollWidth,
controls, overflows, clipped,
fontSizes: [...new Set(controls.map((c) => c.fs))].sort((a, b) => a - b),
radii: [...new Set(controls.map((c) => c.radius))].sort(),
};
})()
"""
def probe(cdp: CDP, url: str, width: int, height: int, settle: float) -> dict:
cdp.call("Emulation.setDeviceMetricsOverride", {
"width": width, "height": height, "deviceScaleFactor": 1, "mobile": width < 700,
})
cdp.call("Page.navigate", {"url": url})
deadline = time.time() + 45
while time.time() < deadline:
st = cdp.call("Runtime.evaluate", {
"expression": "document.readyState", "returnByValue": True}).get("result", {}).get("value")
if st == "complete":
break
time.sleep(0.3)
time.sleep(settle)
res = cdp.call("Runtime.evaluate", {"expression": PROBE, "returnByValue": True, "awaitPromise": True})
return res["result"]["value"]
# ---------------------------------------------------------------- 检查逻辑
def group_rows(controls: list[dict]) -> list[list[dict]]:
"""按垂直重叠把控件分成视觉行(重叠超过较矮者一半即视为同一行)。"""
rows: list[list[dict]] = []
for c in sorted(controls, key=lambda c: (c["y"], c["x"])):
placed = False
for row in rows:
ref = row[0]
top, bot = max(ref["y"], c["y"]), min(ref["y"] + ref["h"], c["y"] + c["h"])
if bot - top > 0.5 * min(ref["h"], c["h"]):
row.append(c)
placed = True
break
if not placed:
rows.append([c])
return rows
def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
out: list[tuple[bool, str, str]] = []
ctrls = g["controls"]
rows = group_rows(ctrls)
# 1 同排等高
mixed = []
for row in rows:
heights = [c["hitH"] for c in row if not c["nestedIcon"]]
if len(heights) < 2:
continue
if max(heights) - min(heights) > 1.5:
mixed.append((row, heights))
detail = "; ".join(
f"y={row[0]['y']:.0f} " + " / ".join(f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」{c['hitH']}px" for c in row)
for row, _ in mixed[:6]
)
out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高"))
# 2 高度取值归一
uniq = sorted({round(c["hitH"], 1) for c in ctrls if not c["optionRow"]})
out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}"))
# 3 点击目标
small = [c for c in ctrls if c["hitH"] < 28 and not c["nestedIcon"]]
small_icon = [c for c in ctrls if c["nestedIcon"] and (c["h"] < 22 or c["w"] < 22)]
det = "; ".join(f"{c['tag']}「{c['label']}」{c['w']}×{c['hitH']}" for c in small[:6]) or "-"
if small_icon:
det += " | 图标按钮过小:" + "; ".join(f"「{c['label']}」{c['w']}×{c['h']}" for c in small_icon[:4])
out.append((not small and not small_icon, "点击目标 ≥28px(嵌内图标 ≥22px)", det))
# 4 标签可见
fields = [c for c in ctrls if c["tag"] in ("input", "select", "textarea")
and c["type"] not in ("checkbox", "radio", "submit", "button")]
nolabel = [c for c in fields if not c["hasVisibleLabel"] and not c["aria"]]
aria_only = [c for c in fields if not c["hasVisibleLabel"] and c["aria"]]
det = "; ".join(f"{c['tag']}[{c['type']}] placeholder=「{c['placeholder']}」" for c in nolabel[:8]) or "全部有可见标签"
if aria_only:
det += f"(另 {len(aria_only)} 个用 aria-label:{', '.join('「' + c['label'] + '」' for c in aria_only[:4])})"
out.append((not nolabel, "输入控件有可见标签或 aria-label", det))
# 5 图标按钮无障碍名
noname = [c for c in ctrls if c["tag"] == "button" and c["iconOnlyBtn"] and not c["aria"]]
det = "; ".join(f"「{c['label']}」" for c in noname[:8]) or "全部有无障碍名"
out.append((not noname, "图标按钮有无障碍名", det))
# 6 字号 / 圆角一致
fs = g["fontSizes"]
# 原生 checkbox/radio 的 0px 圆角由浏览器绘制,不是本项目的样式选择
radii = sorted({c["radius"] for c in ctrls
if not c["intentionalPill"] and c["type"] not in ("checkbox", "radio")})
out.append((len(fs) <= 3, "控件字号 ≤3 种", f"{fs}"))
out.append((len(radii) <= 2, "控件圆角 ≤2 种", f"{radii}"))
# 7 尺寸匹配内容
wide_num = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("number", "date") and c["w"] > 220]
narrow = [c for c in ctrls if c["tag"] == "input" and c["type"] in ("text", "search", "")
and c["w"] < 120 and not c["nestedIcon"]]
det = []
if wide_num:
det.append("数字/日期输入过宽:" + "; ".join(f"「{c['label']}」{c['w']}" for c in wide_num[:4]))
if narrow:
det.append("文本输入过窄:" + "; ".join(f"「{c['label']}」{c['w']}" for c in narrow[:4]))
out.append((not det, "控件尺寸匹配内容", " | ".join(det) or "无异常"))
# 8 无横向溢出
over = g["bodyScrollW"] > g["viewport"]["w"] + 1
det = f"body scrollWidth={g['bodyScrollW']} vs viewport {g['viewport']['w']}"
if over and g["overflows"]:
det += " | 溢出元素:" + "; ".join(f"{o['el']}({o['scrollW']}>{o['clientW']})" for o in g["overflows"][:4])
out.append((not over, "无横向滚动", det))
# 9 提示不裁切
clipped = g["clipped"]
det = "; ".join(f"{c['el']}「{c['text']}」" for c in clipped[:5]) or "无裁切"
out.append((not clipped, "提示/错误文本不被裁切", det))
return out
def main() -> int:
ap = argparse.ArgumentParser(description="UI 对齐与控件一致性自检")
ap.add_argument("--pages", nargs="*", default=DEFAULT_PAGES)
ap.add_argument("--base", default=WEB, help="前端地址(默认 %(default)s)")
ap.add_argument("--width", type=int, default=1500)
ap.add_argument("--height", type=int, default=1200)
ap.add_argument("--settle", type=float, default=3.0, help="导航后再等多久(懒加载/接口回填)")
ap.add_argument("--dump", action="store_true", help="打印几何明细")
ap.add_argument("--json", dest="json_out", help="把原始几何写入该文件")
args = ap.parse_args()
chrome = find_chrome()
if not chrome:
print("未找到 Chrome / Chromium,可用 QLIB_CHROME 指定可执行文件路径", file=sys.stderr)
return 2
chrome = os.environ.get("QLIB_CHROME", chrome)
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-ui-check-")
proc = subprocess.Popen(
[chrome, f"--remote-debugging-port={port}", "--headless=new", "--disable-gpu",
"--no-first-run", "--no-default-browser-check", "--hide-scrollbars",
"--disable-extensions", f"--user-data-dir={profile}",
f"--window-size={args.width},{args.height}", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
ok = bad = 0
raw: dict[str, dict] = {}
try:
ws_url = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for t in json.loads(r.read()):
if t.get("type") == "page":
ws_url = t["webSocketDebuggerUrl"]
break
if ws_url:
break
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(0.3)
if not ws_url:
print("无法连接 Chrome 调试端口", file=sys.stderr)
return 2
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
widths = [args.width]
if args.width != 375:
widths.append(375) # 小屏复核(UX 规范要求 375px 验证)
for path in args.pages:
for w in widths:
url = args.base.rstrip("/") + path
label = f"{path} @{w}px"
print(f"\n[{label}]", flush=True)
try:
g = probe(cdp, url, w, args.height, args.settle)
except Exception as e: # noqa: BLE001 - 自检脚本要把失败讲清楚而不是崩掉
print(f" ❌ 探针失败:{e}", flush=True)
bad += 1
continue
raw[f"{path}@{w}"] = g
if args.dump:
for row in group_rows(g["controls"]):
box = " | ".join(
f"{c['tag']}{'[' + c['type'] + ']' if c['type'] else ''}「{c['label']}」"
f"{c['w']}×{c['h']}@y{c['y']:.0f} fs{c['fs']}" for c in row)
print(f" {box}", flush=True)
for good, name, detail in check_geometry(g):
mark = "✅" if good else "❌"
if good:
ok += 1
else:
bad += 1
print(f" {mark} {name} — {detail}", flush=True)
if args.json_out:
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=1)
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(profile, ignore_errors=True)
print(f"\n结果:{ok} 项通过 / {bad} 项失败", flush=True)
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())