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:
@@ -72,12 +72,13 @@ cd backend
|
||||
uv sync # 含 pyqlib(GitHub 源码依赖,固定 commit)。若网络下载困难/超时,按 AGENT.md §0 设置代理 192.168.1.160:3128 后重试
|
||||
|
||||
# 3. 运行测试
|
||||
uv run pytest # 全量 341 条
|
||||
uv run pytest # 全量 388 条
|
||||
uv run ruff check app tests
|
||||
|
||||
# 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 # 回测结果结构契约
|
||||
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,需前端已启动)
|
||||
|
||||
# 4. 启动开发服务
|
||||
uv run uvicorn app.main:app --reload --port 8000
|
||||
|
||||
+55
-2
@@ -439,6 +439,56 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
|
||||
# 只验接口与页面(跳过回测 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
|
||||
@@ -496,8 +546,9 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
|
||||
|
||||
```bash
|
||||
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 分钟)
|
||||
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,约 3 分钟)
|
||||
```
|
||||
|
||||
覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」
|
||||
@@ -505,7 +556,9 @@ PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回
|
||||
**数据库目标守卫**(`192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、
|
||||
**QlibEngine 数据管线**(bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment
|
||||
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
|
||||
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)。
|
||||
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
|
||||
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出,
|
||||
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
{/* 整个单元都是热区: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>
|
||||
|
||||
@@ -266,12 +266,14 @@ function FactorRow(props: {
|
||||
<>
|
||||
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<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
@@ -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 / 规格表 / 代码块 ---------- */
|
||||
|
||||
|
||||
@@ -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,12 +217,22 @@ export default function SelectionPage() {
|
||||
|
||||
{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" }}>
|
||||
评分因子与权重
|
||||
</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="row" style={{ gap: 8, marginBottom: 8 }} key={i}>
|
||||
<div className="factor-row" key={i}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 2 }}
|
||||
aria-label={`第 ${i + 1} 个因子的名称`}
|
||||
value={r.name}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
@@ -235,10 +245,12 @@ export default function SelectionPage() {
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
className="input mono"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.1"
|
||||
min={0}
|
||||
aria-label={`第 ${i + 1} 个因子的权重`}
|
||||
value={r.weight}
|
||||
onChange={(e) => {
|
||||
const next = [...factorRows];
|
||||
@@ -246,35 +258,56 @@ export default function SelectionPage() {
|
||||
setFactorRows(next);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
<Btn
|
||||
icon="x"
|
||||
disabled={factorRows.length <= 1}
|
||||
aria-label={`删除第 ${i + 1} 个因子`}
|
||||
title="删除该因子"
|
||||
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))} />
|
||||
</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"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
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>
|
||||
<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="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }} key={i}>
|
||||
<div className="cond-row" key={i}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 2 }}
|
||||
aria-label={`第 ${i + 1} 个条件的字段`}
|
||||
value={isRef ? "close_ma60_ref" : c.field}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
@@ -291,7 +324,7 @@ export default function SelectionPage() {
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
aria-label={`第 ${i + 1} 个条件的比较符`}
|
||||
value={c.op}
|
||||
onChange={(e) => {
|
||||
const next = [...conds];
|
||||
@@ -303,8 +336,8 @@ export default function SelectionPage() {
|
||||
</select>
|
||||
{!isRef ? (
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
className="input mono"
|
||||
aria-label={`第 ${i + 1} 个条件的取值`}
|
||||
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
|
||||
value={String(c.value ?? "")}
|
||||
onChange={(e) => {
|
||||
@@ -319,18 +352,26 @@ export default function SelectionPage() {
|
||||
setConds(next);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
className="btn"
|
||||
) : (
|
||||
/* 占位保持第四列(操作列)不串位 */
|
||||
<span />
|
||||
)}
|
||||
<Btn
|
||||
icon="x"
|
||||
disabled={conds.length <= 1}
|
||||
aria-label={`删除第 ${i + 1} 个条件`}
|
||||
title="删除该条件"
|
||||
onClick={() => setConds(conds.filter((_, j) => j !== i))}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn" onClick={() => setConds([...conds, emptyCondition()])}>+ 条件</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Btn icon="layers" size="sm" onClick={() => setConds([...conds, emptyCondition()])}>
|
||||
添加条件
|
||||
</Btn>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+22
-1
@@ -1,3 +1,24 @@
|
||||
# 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` | 一键起后端 + 前端(开发用) |
|
||||
@@ -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),
|
||||
encoding="utf-8",
|
||||
)
|
||||
rng = np.random.default_rng(7)
|
||||
# 合成行情用解析式生成(无随机项),不需要随机数发生器
|
||||
for k, sym in enumerate(SYMBOLS):
|
||||
(tmp / "features" / sym).mkdir(parents=True)
|
||||
drift = 0.002 + 0.001 * k
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user