fix(billing-ui): 规则表列宽锁定,消除表头/按钮重叠

全部靠截图 + 几何测量发现,DOM 断言当时全绿:

- table-layout:fixed 把 954px 均分成 9×106px,写在 <td> 上的每列宽度全部失效 →
  相邻表头叠在一起,源 URL 输入框被压成 "https:"。
- 把宽度改到 <th> 并去掉 fixed → 浏览器改按内容算,<th> 宽度又被忽略,币种列
  仍塌到 48px(输入框只剩 24px,装不下 "USD")。
- 恢复 fixed + 宽度移到 <colgroup>:fixed 下只有首行宽度生效,而「按模型价格」
  格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则
  最宽。colgroup 一次性锁死九列,所有行一致。
- 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案
  留在 title。
- 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数
  列匀出。
- 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」
  按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。
- 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。

判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 /
改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用
strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配
style='…' 字面量后才真正失败。
This commit is contained in:
JianFeeeee
2026-10-02 15:29:29 +08:00
parent c19b8e6394
commit 400b6c35a5
2 changed files with 129 additions and 16 deletions

View File

@ -1039,15 +1039,21 @@ plugin.ui = {
// ---- row rendering -------------------------------------------------------
function modelBlock(models, T) {
var keys = Object.keys(models || {});
// Every child must be allowed to SHRINK: the price cell is 262px wide and
// the four parts (name + prompt + completion + delete) used to be
// 80+96+96+38 plus gaps = ~330px, so the × button overflowed the cell by
// 78px and landed on top of the neighbouring column's Delete button.
// flex:1 1 0 with min-width:0 lets the inputs share the cell instead of
// pushing past it; the row wraps rather than overlapping.
var rows = keys.map(function (m) {
var t = models[m] || {};
return "<div class='row' style='gap:6px;margin:3px 0'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" +
return "<div class='row' style='gap:4px;margin:3px 0;flex-wrap:wrap'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>&times;</button></div>";
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>&times;</button></div>";
}).join("");
return "<div class='m-rows'>" + rows + "</div>" +
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
@ -1068,14 +1074,14 @@ plugin.ui = {
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td style='width:78px'><select class='r-mode' style='width:100%;box-sizing:border-box'>" + opts + "</select></td>" +
"<td style='width:64px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:76px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:58px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:88px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:62px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:88px'><select class='r-mode' style='width:100%;box-sizing:border-box;padding:2px;font-size:12px'>" + opts + "</select></td>" +
"<td style='width:70px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:80px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:68px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:96px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:78px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
}
@ -1247,11 +1253,43 @@ plugin.ui = {
"<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" +
// Widths live on <th> AND the layout is fixed — the two go together.
//
// Measured: with table-layout:fixed and widths on the <td>s, the 954px
// container was divided into 9 equal 106px columns and every per-column
// width was discarded, so two adjacent headers sat on top of each other
// and the URL input was squeezed to "https:". With auto layout the
// browser ignores the <th> widths entirely and sizes from content, so
// the 70px currency column still collapsed to 48px (24px of input —
// too narrow to show "USD"). Under fixed layout the FIRST ROW's widths
// win, so the <th> widths below are the ones that take effect, and the
// total (1046px) exceeds the container on purpose: the wrapper scrolls.
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed'>" +
// colgroup, not th widths: under table-layout:fixed the first row's
// widths win, and the model-price cell holds a variable-width list of
// price rows whose content width varies per rule. Measuring that cell
// gave 352px on one rule and 206px on another — the whole row's
// layout then depended on WHICH RULE happened to be widest. A colgroup
// fixes the nine columns once and keeps every row identical.
"<colgroup>" +
// The last column holds a 「删除 / Delete」 button: 58px wrapped it
// to two lines and the button grew taller than the row. 74px fits it
// on one line; the peak-multiplier column gives up the difference
// (it only ever holds a single digit such as "2").
[214, 80, 62, 70, 66, 74, 62, 262, 74]
.map(function (w) { return "<col style='width:" + w + "px'>"; }).join("") +
"</colgroup>" +
"<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></tr>" +
[[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult],
[T.peakHours], [T.peakDays], [T.models], [""]]
.map(function (c) {
// nowrap + ellipsis: the bilingual headers ("峰段星期 (UTC 1=周一)")
// are far wider than their column, and without this they spilled
// into the neighbouring header. The full text stays in title.
return "<th style='white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
esc(c[0]) + "'>" + esc(c[0]) + "</th>";
}).join("") +
"</tr>" +
(rules.length
? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +