From 400b6c35a53c6854c50bd76e482c7a2ecc81406b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 15:29:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(billing-ui):=20=E8=A7=84=E5=88=99=E8=A1=A8?= =?UTF-8?q?=E5=88=97=E5=AE=BD=E9=94=81=E5=AE=9A=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E8=A1=A8=E5=A4=B4/=E6=8C=89=E9=92=AE=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全部靠截图 + 几何测量发现,DOM 断言当时全绿: - table-layout:fixed 把 954px 均分成 9×106px,写在 上的每列宽度全部失效 → 相邻表头叠在一起,源 URL 输入框被压成 "https:"。 - 把宽度改到 并去掉 fixed → 浏览器改按内容算, 宽度又被忽略,币种列 仍塌到 48px(输入框只剩 24px,装不下 "USD")。 - 恢复 fixed + 宽度移到 :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='…' 字面量后才真正失败。 --- internal/lua/billing_ui_test.go | 75 ++++++++++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 70 ++++++++++++++++++++++------- 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 4f73af1..a9b49f0 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -339,3 +339,78 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) { "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 → 954px divided into 9 equal +// 106px columns, every declared width discarded. +// - widths on WITHOUT fixed → browser sizes from content and ignores +// them; the 70px currency column still collapsed to 48px (24px of input). +// - fixed + 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 widths below are ignored and columns size from content") + } + colStart := strings.Index(js, "") + if colStart < 0 { + t.Fatal("the rule table declares no ; per-column widths on " + + " or 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 ""`) { + t.Error("colgroup entries are no longer emitted as ") + } + // 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") + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 0c70ae7..61cede8 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -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 "
" + - "" + + return "
" + + "" + "" + + "' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" + "" + - "
"; + "' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" + + "
"; }).join(""); return "
" + rows + "
" + ""; @@ -1068,14 +1074,14 @@ plugin.ui = { "" + (unknown ? "
⚠ " + esc(T.warnNoSource) + "
" : "") + "" + - "" + - "" + - "" + - "" + - "" + - "" + + "" + + "" + + "" + + "" + + "" + + "" + "" + modelBlock(rule.models, T) + "" + ""; } @@ -1247,11 +1253,43 @@ plugin.ui = { "" + "" + "" + + // Widths live on AND the layout is fixed — the two go together. + // + // Measured: with table-layout:fixed and widths on the 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 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 widths below are the ones that take effect, and the + // total (1046px) exceeds the container on purpose: the wrapper scrolls. "
" + + // 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. + "" + + // 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 ""; }).join("") + + "" + "" + - "" + - "" + - "" + + [[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 ""; + }).join("") + + "" + (rules.length ? rules.map(function (r) { return ruleRow(r, known, T); }).join("") : "") +
" + esc(T.url) + "" + esc(T.mode) + "" + esc(T.currency) + "" + esc(T.monthly) + "" + esc(T.peakMult) + "" + esc(T.peakHours) + "" + esc(T.peakDays) + "" + esc(T.models) + "
" + esc(c[0]) + "
" + esc(T.noRules) + "