mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 15:07:51 +00:00
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:
@ -339,3 +339,78 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
|||||||
"table is misaligned from the first differing column on", th, td)
|
"table is misaligned from the first differing column on", th, td)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
|
||||||
|
// only a screenshot caught: two adjacent headers rendered on top of each other,
|
||||||
|
// the URL input squeezed to "https:", and the per-model × button landing 78px
|
||||||
|
// past its cell onto the neighbouring Delete button.
|
||||||
|
//
|
||||||
|
// The measurement that found each one:
|
||||||
|
// - table-layout:fixed with widths on <td> → 954px divided into 9 equal
|
||||||
|
// 106px columns, every declared width discarded.
|
||||||
|
// - widths on <th> WITHOUT fixed → browser sizes from content and ignores
|
||||||
|
// them; the 70px currency column still collapsed to 48px (24px of input).
|
||||||
|
// - fixed + <th> widths → they take effect, but the model-price cell's width
|
||||||
|
// then depended on WHICH RULE was widest (352px vs 206px).
|
||||||
|
//
|
||||||
|
// The fix is all three together: fixed layout, a colgroup, and flex children
|
||||||
|
// that may shrink.
|
||||||
|
func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
|
||||||
|
page, _ := billingUI(t)
|
||||||
|
js := strings.Join(extractScripts(page), "\n")
|
||||||
|
// Match the ATTRIBUTE, not the bare string: a substring check on
|
||||||
|
// "table-layout:fixed" is satisfied by the explanatory comment that sits
|
||||||
|
// three lines above the tag, so removing the attribute from the table still
|
||||||
|
// passed (mutation-verified). Require it inside a style='...' literal.
|
||||||
|
if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
|
||||||
|
t.Error("the rule table must stay table-layout:fixed — with auto layout " +
|
||||||
|
"the <colgroup> widths below are ignored and columns size from content")
|
||||||
|
}
|
||||||
|
colStart := strings.Index(js, "<colgroup>")
|
||||||
|
if colStart < 0 {
|
||||||
|
t.Fatal("the rule table declares no <colgroup>; per-column widths on " +
|
||||||
|
"<th> or <td> are advisory and get recomputed from content")
|
||||||
|
}
|
||||||
|
// The colgroup is built from a JS array of pixel widths, so assert on the
|
||||||
|
// array, not on literal "<col " occurrences — counting the literal found
|
||||||
|
// exactly one and reported "1 columns, want 9" against correct code.
|
||||||
|
if !strings.Contains(js, "[214, 80, 62, 70, 66, 74, 62, 262, 74]") {
|
||||||
|
t.Error("the rule table's colgroup widths changed or vanished; measured " +
|
||||||
|
"values were [214 80 62 70 66 74 62 262 74] for a 954px pane — the " +
|
||||||
|
"delete column needs 74px or the button wraps to two lines")
|
||||||
|
}
|
||||||
|
if !strings.Contains(js, `"<col style='width:" + w + "px'>"`) {
|
||||||
|
t.Error("colgroup entries are no longer emitted as <col style='width:Npx'>")
|
||||||
|
}
|
||||||
|
// Every price-row INPUT must be allowed to shrink, or the row's fixed widths
|
||||||
|
// sum past the cell and the × button lands on the next column. The × button
|
||||||
|
// is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
|
||||||
|
// delete button is unclickable.
|
||||||
|
for _, needle := range []string{"m-name", "m-p", "m-c"} {
|
||||||
|
i := strings.Index(js, "class='"+needle+"'")
|
||||||
|
if i < 0 {
|
||||||
|
i = strings.Index(js, "class='ghost small "+needle+"'")
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
t.Errorf("price row lost the %s input/button", needle)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
window := js[i : i+220]
|
||||||
|
if !strings.Contains(window, "flex:") {
|
||||||
|
t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
|
||||||
|
"against a 262px cell and overflows onto the next column", needle)
|
||||||
|
}
|
||||||
|
if !strings.Contains(window, "min-width:0") {
|
||||||
|
t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
|
||||||
|
"content width, which is how the × button ended up 78px past its cell", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i := strings.Index(js, "class='ghost small m-del'")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("the per-model delete button is gone")
|
||||||
|
}
|
||||||
|
if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
|
||||||
|
t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
|
||||||
|
"control and must never be squeezed by the flexible inputs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1039,15 +1039,21 @@ plugin.ui = {
|
|||||||
// ---- row rendering -------------------------------------------------------
|
// ---- row rendering -------------------------------------------------------
|
||||||
function modelBlock(models, T) {
|
function modelBlock(models, T) {
|
||||||
var keys = Object.keys(models || {});
|
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 rows = keys.map(function (m) {
|
||||||
var t = models[m] || {};
|
var t = models[m] || {};
|
||||||
return "<div class='row' style='gap:6px;margin:3px 0'>" +
|
return "<div class='row' style='gap:4px;margin:3px 0;flex-wrap:wrap'>" +
|
||||||
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" +
|
"<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) +
|
"<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) +
|
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
|
||||||
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
|
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
|
||||||
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>×</button></div>";
|
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>×</button></div>";
|
||||||
}).join("");
|
}).join("");
|
||||||
return "<div class='m-rows'>" + rows + "</div>" +
|
return "<div class='m-rows'>" + rows + "</div>" +
|
||||||
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
|
"<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'>" +
|
"<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'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
(unknown ? "<div style='color:#e0a33e;font-size:11px'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
"<td style='width:78px'><select class='r-mode' style='width:100%;box-sizing:border-box'>" + opts + "</select></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:64px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box'></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:76px'><input class='r-sub' type='number' step='0.01' value='" +
|
"<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'></td>" +
|
(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:58px'><input class='r-mult' type='number' step='0.1' value='" +
|
"<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'></td>" +
|
(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: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: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:62px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box'></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='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
|
||||||
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
|
"<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>" +
|
"<span class='grow'></span>" +
|
||||||
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
|
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
|
||||||
"</div>" +
|
"</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'>" +
|
"<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'>" +
|
"<tr style='text-align:left;opacity:.65'>" +
|
||||||
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
|
[[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult],
|
||||||
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
|
[T.peakHours], [T.peakDays], [T.models], [""]]
|
||||||
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></tr>" +
|
.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.length
|
||||||
? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
|
? 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>") +
|
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +
|
||||||
|
|||||||
Reference in New Issue
Block a user