Files
qlib/scripts/verify_ui_alignment.py
T
Simon 9aaca12751 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 小屏)。
2026-09-27 09:01:55 +08:00

488 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())