From 545453c93edf1bf4523965cf17d730f700a6befc Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 14:16:37 +0800 Subject: [PATCH] =?UTF-8?q?feat=E2=9C=A8:=20infer=20column=20width=20from?= =?UTF-8?q?=20column=5Ftype=20before=20rendering=20(PRD=20010=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vue.go.template (F4) only had a flat fallback for an unconfigured colWidth: 110 for datetime, 120 for everything else. R2 asks for a precise per-type inference instead - varchar(n) tiered by n, tinyint(1) narrower than a general integer, text/blob wide - sized so a typical table's list columns land inside go-admin-ui's ~580px text-column budget. text/template cannot parse "varchar(255)" itself, so this has to run in gen.go before the template executes, not in the template. Judgment is columnType, not goType, same as F1/F2's earlier reversal (API契约.md §1.1): sys_tables.go:323-338 gives every non-primary-key int/tinyint/bigint/decimal column goType "string", so goType alone cannot tell a boolean flag from a bigint from a name column. InferColumnWidth(columnType string) int is exported and pure per 测试用例.md §2.5's own request, so it can be pinned with an exact input/output table rather than only asserting "the rendered page happens not to overflow" - see column_width_test.go, including the tinyint(1)-vs-tinyint(4) and the varchar tier-boundary cases. applyInferredColumnWidths only touches a column already at the 0 sentinel, wired into both Preview and NOActionsGen ahead of the template execute calls; a column already configured (by the user or F6) is left untouched, also pinned by a test. Verified against the real NOActionsGen call path (not just the unit test): generated a table mixing every branch (tinyint(1), datetime, decimal, varchar at three tiers, text, and one pre-configured column) and printed the resulting tab.Columns[i].ColWidth after generation - every value matched InferColumnWidth's own table, and the pre-configured column's 333 was left untouched. --- app/other/apis/tools/column_width.go | 126 ++++++++++++++++++++++ app/other/apis/tools/column_width_test.go | 80 ++++++++++++++ app/other/apis/tools/gen.go | 5 + 3 files changed, 211 insertions(+) create mode 100644 app/other/apis/tools/column_width.go create mode 100644 app/other/apis/tools/column_width_test.go diff --git a/app/other/apis/tools/column_width.go b/app/other/apis/tools/column_width.go new file mode 100644 index 00000000..efdf3c83 --- /dev/null +++ b/app/other/apis/tools/column_width.go @@ -0,0 +1,126 @@ +package tools + +import ( + "regexp" + "strconv" + "strings" + + "go-admin/app/other/models/tools" +) + +// columnLengthPattern pulls the first parenthesized integer out of a MySQL +// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in +// "decimal(10,2)". Works regardless of trailing modifiers such as +// "unsigned" or a charset clause, since it only looks for the first digits +// after the first '('. +var columnLengthPattern = regexp.MustCompile(`\((\d+)`) + +// InferColumnWidth backs R2's fallback path: when a column's colWidth is +// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type +// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length, +// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and +// returns a px width sized to fit inside go-admin-ui's ~580px text-column +// budget for a 1280px viewport (its AGENTS.md "列宽" section). +// +// The judgment has to be columnType, not goType: sys_tables.go:323-338 +// gives every non-primary-key int/tinyint/bigint/decimal column goType +// "string" (a bare substring match on "int" that also catches "tinyint"/ +// "bigint", intentional at import time but useless for telling a boolean +// flag from a bigint), so goType alone cannot distinguish a switch column +// from a price column from a name column. This is the same judgment call +// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2. +// GoType is not consulted anywhere in this function, including for +// datetime/timestamp columns - those are matched on columnType too. +// +// Exported and pure (string in, int out) so QA can pin an exact input/output +// table against it directly (测试用例.md §2.5's own recommendation), rather +// than only being able to assert "the rendered page happens not to overflow". +func InferColumnWidth(columnType string) int { + ct := strings.ToLower(strings.TrimSpace(columnType)) + + switch { + case strings.HasPrefix(ct, "tinyint(1)"): + // MySQL's own shape for a boolean/status flag - a tag or a switch, + // not text, so it wants less room than a general numeric column. + return 70 + + case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"), + strings.Contains(ct, "date"), strings.Contains(ct, "time"): + return 110 + + case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"), + strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"), + strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"), + strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"): + // API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these + // together rather than sizing each individually - none of them need + // more than a handful of digits' worth of width. + return 90 + + case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"): + return varcharWidth(columnLength(ct)) + + case strings.Contains(ct, "text"), strings.Contains(ct, "blob"): + // longtext/mediumtext/text/blob: no declared length to size against, + // and content here is free-form, so this errs wide rather than + // guessing a number the actual content will not respect. + return 260 + + default: + // Unrecognized column_type (an enum, a json column, a driver this + // codebase does not special-case, ...). Matches the flat fallback + // vue.go.template already used for every non-datetime column before + // this function existed, so a type this does not recognize is no + // worse off than the old blanket default. + return 120 + } +} + +// varcharWidth tiers a char/varchar column by its declared length. The +// tiers are deliberately coarse - R2 only asks for "common tables land in +// the 580px budget", not pixel-perfect sizing per character. +func varcharWidth(n int) int { + switch { + case n <= 0: + // Length did not parse (unexpected shape) - mid tier, not the + // narrowest, since an un-lengthed varchar is unlikely to be a + // short code column. + return 150 + case n <= 10: + return 90 + case n <= 20: + return 110 + case n <= 50: + return 150 + case n <= 100: + return 200 + default: + return 240 + } +} + +// columnLength extracts the first parenthesized integer, or 0 if the type +// string does not have one (already-lowercased input expected). +func columnLength(columnType string) int { + m := columnLengthPattern.FindStringSubmatch(columnType) + if m == nil { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + return n +} + +// applyInferredColumnWidths fills in InferColumnWidth's result for every +// column still at the 0 "unconfigured" sentinel, in place, before the +// template that reads .ColWidth runs. A column the user (or F6's config +// page) already gave an explicit width is left untouched. +func applyInferredColumnWidths(columns []tools.SysColumns) { + for i := range columns { + if columns[i].ColWidth == 0 { + columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType) + } + } +} diff --git a/app/other/apis/tools/column_width_test.go b/app/other/apis/tools/column_width_test.go new file mode 100644 index 00000000..0ebf3ea4 --- /dev/null +++ b/app/other/apis/tools/column_width_test.go @@ -0,0 +1,80 @@ +package tools + +import ( + "testing" + + "go-admin/app/other/models/tools" +) + +// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own +// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测 +// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function +// it pins rather than only living in a later QA-owned suite. +func TestInferColumnWidth(t *testing.T) { + cases := []struct { + name string + columnType string + want int + }{ + {"boolean/status flag", "tinyint(1)", 70}, + {"boolean flag, case-insensitive", "TINYINT(1)", 70}, + {"datetime", "datetime", 110}, + {"timestamp", "timestamp", 110}, + {"date only", "date", 110}, + {"time only", "time", 110}, + + {"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90}, + {"smallint", "smallint(6)", 90}, + {"mediumint", "mediumint(9)", 90}, + {"int", "int(11)", 90}, + {"bigint", "bigint(20)", 90}, + {"decimal", "decimal(10,2)", 90}, + {"float", "float", 90}, + {"double", "double", 90}, + + {"varchar short code", "varchar(8)", 90}, + {"varchar at the 10 boundary", "varchar(10)", 90}, + {"varchar just past the 10 boundary", "varchar(11)", 110}, + {"varchar at the 20 boundary", "varchar(20)", 110}, + {"varchar mid length", "varchar(32)", 150}, + {"varchar at the 50 boundary", "varchar(50)", 150}, + {"varchar just past the 50 boundary", "varchar(51)", 200}, + {"varchar(255), the common default", "varchar(255)", 240}, + {"char, fixed-width", "char(2)", 90}, + {"varchar with no parsed length", "varchar", 150}, + + {"text, no length to size against", "text", 260}, + {"longtext", "longtext", 260}, + {"mediumtext", "mediumtext", 260}, + {"blob", "blob", 260}, + + {"unrecognized type falls back to the old flat default", "json", 120}, + {"empty column_type falls back to the old flat default", "", 120}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := InferColumnWidth(tc.columnType); got != tc.want { + t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want) + } + }) + } +} + +func TestApplyInferredColumnWidths(t *testing.T) { + columns := []tools.SysColumns{ + {JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0}, + {JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured + } + + applyInferredColumnWidths(columns) + + if columns[0].ColWidth == 0 { + t.Error("unconfigured column: want an inferred non-zero width, still 0") + } + if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want { + t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth) + } + if columns[1].ColWidth != 300 { + t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth) + } +} diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 7c4d298d..49bf3caa 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -123,6 +123,9 @@ func (e Gen) Preview(c *gin.Context) { } tab, _ := table.Get(db, false) + // R2: infer a width for any column the config page left at colWidth's 0 + // sentinel, before vue.go.template reads .ColWidth - see column_width.go. + applyInferredColumnWidths(tab.Columns) var b1 bytes.Buffer err = t1.Execute(&b1, tab) var b2 bytes.Buffer @@ -210,6 +213,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Context = c log := e.GetLogger() tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + // R2: see the matching call and comment in Preview above. + applyInferredColumnWidths(tab.Columns) basePath := "template/v4/" routerFile := basePath + "no_actions/router_check_role.go.template"