diff --git a/README.md b/README.md index 0706431..8a0c949 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index bf50f0d..ab0b837 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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)。 --- diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index 80be2f9..bf95d8c 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -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(".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} />
- 0} onClick={run}> + {running ? "后台运行中…" : "运行回测"} diff --git a/frontend/web/app/experiments/page.tsx b/frontend/web/app/experiments/page.tsx index f3a7c3f..f3450f1 100644 --- a/frontend/web/app/experiments/page.tsx +++ b/frontend/web/app/experiments/page.tsx @@ -320,8 +320,7 @@ function ExperimentsInner() { tools={
{ @@ -331,8 +330,7 @@ function ExperimentsInner() { aria-label="搜索实验" /> togglePick(e.id)} - aria-label={`选择 ${e.id} 用于对比`} - /> + {/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */} + {e.id} {kindView(e.kind)} diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx index b66522a..5d11e6b 100644 --- a/frontend/web/app/factors/page.tsx +++ b/frontend/web/app/factors/page.tsx @@ -266,12 +266,14 @@ function FactorRow(props: { <> e.stopPropagation()}> - + {f.name} diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css index 6942d6f..b4ec78b 100644 --- a/frontend/web/app/globals.css +++ b/frontend/web/app/globals.css @@ -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 / 规格表 / 代码块 ---------- */ diff --git a/frontend/web/app/selection/page.tsx b/frontend/web/app/selection/page.tsx index 1ea29e4..72a209b 100644 --- a/frontend/web/app/selection/page.tsx +++ b/frontend/web/app/selection/page.tsx @@ -207,8 +207,8 @@ export default function SelectionPage() { setAsOf(e.target.value)} /> - -