From aa7a92664ddc8359002a9fbff74e10793f3346d6 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 13:33:42 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat=E2=9C=A8:=20add=20sys=5Fcolumns.col?= =?UTF-8?q?=5Fwidth=20and=20default=5Fvalue=20(PRD=20010=20F1/F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Back the code generator's Vue 3 template migration: col_width lets R2's column-width inference be overridden per field, and default_value lets R1/A6's "unconfigured rows still generate a usable page" guarantee hold for generated forms. Both use a sentinel default (0 / "") rather than NULL so "unconfigured" has exactly one representation - see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1. The migration and the model change land together: AddColumn reads the column definition off tools.SysColumns's own gorm tag, so splitting them across commits would leave one of them failing to compile. --- app/other/models/tools/sys_columns.go | 13 +++++ .../1786700010000_gen_column_layout_fields.go | 56 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go diff --git a/app/other/models/tools/sys_columns.go b/app/other/models/tools/sys_columns.go index ff6b8ddf..41db6176 100644 --- a/app/other/models/tools/sys_columns.go +++ b/app/other/models/tools/sys_columns.go @@ -45,6 +45,19 @@ type SysColumns struct { CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"` UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"` + // ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3). + // Both use a sentinel default (0 / "") rather than NULL - see + // docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer + // int/string field can never read NULL back out, and NULL would give + // "unconfigured" two representations instead of one. Callers test + // ColWidth == 0 / DefaultValue == "" to detect "not configured". + // + // ColWidth deliberately has no gorm size tag: this codebase's "size:N" + // convention on numeric fields maps to a narrow SQL integer type (see + // column_width_test.go), and col_width needs to hold values up to 800. + ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"` + DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"` + common.ModelTime } diff --git a/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go b/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go new file mode 100644 index 00000000..28b81121 --- /dev/null +++ b/cmd/migrate/migration/version/1786700010000_gen_column_layout_fields.go @@ -0,0 +1,56 @@ +package version + +import ( + "runtime" + + "gorm.io/gorm" + + "go-admin/app/other/models/tools" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" +) + +// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2 +// (代码生成器前端模板迁移 Vue 3). +// +// col_width backs R2's column-width inference fallback and default_value +// backs R1/A6's "unconfigured rows still generate a usable page" guarantee - +// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both +// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/ +// string field can never read NULL back out, and NULL would give +// "unconfigured" two representations instead of one. +// +// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime +// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through) +// rather than cmd/migrate/migration/models, matching every migration in this +// directory since sys_columns was converted - see +// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's +// TestPostConversionMigrationsAvoidFrozenSeedModels. +// +// Hard prerequisite: tools.SysColumns must already declare ColWidth and +// DefaultValue (with the gorm tags in the doc above) by the time this file +// is compiled - AddColumn reads the column definition off the struct's own +// tag, not off anything in this file. Landing this migration without that +// model change first makes HasColumn/AddColumn silently do nothing (the +// field lookup fails and AddColumn returns an error naming the missing +// field), which fails loudly rather than silently - see the "no such field" +// error - so this is caught at migrate time, not left for a report later. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields) +} + +func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error { + m := db.Migrator() + if !m.HasColumn(&tools.SysColumns{}, "ColWidth") { + if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil { + return err + } + } + if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") { + if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil { + return err + } + } + return db.Create(&common.Migration{Version: version}).Error +} From 28eba9207780d8658a4e25ebcc88c75eeeaf8ee2 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 13:55:56 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat=E2=9C=A8:=20generate=20a=20typed=20.?= =?UTF-8?q?ts=20API=20module=20instead=20of=20.js=20(PRD=20010=20F5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite template/v4/js.go.template (renamed to ts.go.template) to emit TypeScript matching go-admin-ui's src/api/demo/product.ts: an interface per row and per query params, typed CRUD functions, and a del{Class} that takes Id[] like useRemove expects. GoType maps to number for int/int64/float32/float64 and string otherwise, since go-admin-ui's request() types the {code,data,msg} envelope rather than the payload. Preview's response map key changes from template/js.go.template to template/api.ts.template so its Tab label matches the new content; NOActionsGen writes the file with a .ts extension. Verified end to end: generated a real table's output, copied it into go-admin-ui and ran vue-tsc --noEmit and eslint against it - both clean. --- app/other/apis/tools/gen.go | 12 +++--- template/v4/js.go.template | 47 ----------------------- template/v4/ts.go.template | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 53 deletions(-) delete mode 100644 template/v4/js.go.template create mode 100644 template/v4/ts.go.template diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 7fb38168..7f32c0fc 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -45,10 +45,10 @@ func (e Gen) Preview(c *gin.Context) { e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error())) return } - t3, err := template.ParseFiles("template/v4/js.go.template") + t3, err := template.ParseFiles("template/v4/ts.go.template") if err != nil { log.Error(err) - e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error())) + e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error())) return } t4, err := template.ParseFiles("template/v4/vue.go.template") @@ -102,7 +102,7 @@ func (e Gen) Preview(c *gin.Context) { mp := make(map[string]interface{}) mp["template/model.go.template"] = b1.String() mp["template/api.go.template"] = b2.String() - mp["template/js.go.template"] = b3.String() + mp["template/api.ts.template"] = b3.String() mp["template/vue.go.template"] = b4.String() mp["template/router.go.template"] = b5.String() mp["template/dto.go.template"] = b6.String() @@ -191,10 +191,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error())) return } - t4, err := template.ParseFiles(basePath + "js.go.template") + t4, err := template.ParseFiles(basePath + "ts.go.template") if err != nil { log.Error(err) - e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error())) + e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error())) return } t5, err := template.ParseFiles(basePath + "vue.go.template") @@ -245,7 +245,7 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go") pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go") pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go") - pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js") + pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts") pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue") pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go") pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go") diff --git a/template/v4/js.go.template b/template/v4/js.go.template deleted file mode 100644 index db3d4a27..00000000 --- a/template/v4/js.go.template +++ /dev/null @@ -1,47 +0,0 @@ -import request from '@/utils/request' - -// 查询{{.ClassName}}列表 -export function list{{.ClassName}}(query) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'get', - params: query - }) -} - -// 查询{{.ClassName}}详细 -export function get{{.ClassName}} ({{.PkJsonField}}) { - return request({ - url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}}, - method: 'get' - }) -} - - -// 新增{{.ClassName}} -export function add{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'post', - data: data - }) -} - -// 修改{{.ClassName}} -export function update{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}}, - method: 'put', - data: data - }) -} - -// 删除{{.ClassName}} -export function del{{.ClassName}}(data) { - return request({ - url: '/api/v1/{{.ModuleName}}', - method: 'delete', - data: data - }) -} - diff --git a/template/v4/ts.go.template b/template/v4/ts.go.template new file mode 100644 index 00000000..f6f62f65 --- /dev/null +++ b/template/v4/ts.go.template @@ -0,0 +1,75 @@ +import request from '@/utils/request' +import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api' + +export interface {{.ClassName}} { +{{- range .Columns}} + {{.JsonField}}?: {{if eq .GoType "int" -}} + number + {{- else if eq .GoType "int64" -}} + number + {{- else if eq .GoType "float32" -}} + number + {{- else if eq .GoType "float64" -}} + number + {{- else -}} + string + {{- end}} +{{- end}} +} + +export interface {{.ClassName}}Query { +{{- range .Columns}} +{{- if eq .IsQuery "1"}} + {{.JsonField}}?: {{if eq .GoType "int" -}} + number + {{- else if eq .GoType "int64" -}} + number + {{- else if eq .GoType "float32" -}} + number + {{- else if eq .GoType "float64" -}} + number + {{- else -}} + string + {{- end}} +{{- end}} +{{- end}} +} + +export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) { + return request>>({ + url: '/api/v1/{{.ModuleName}}', + method: 'get', + params: query + }) +} + +export function get{{.ClassName}}({{.PkJsonField}}: number) { + return request>({ + url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}}, + method: 'get' + }) +} + +export function add{{.ClassName}}(data: {{.ClassName}}) { + return request>({ + url: '/api/v1/{{.ModuleName}}', + method: 'post', + data + }) +} + +export function update{{.ClassName}}(data: {{.ClassName}}) { + return request>({ + url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}}, + method: 'put', + data + }) +} + +export function del{{.ClassName}}(ids: Id[]) { + return request>({ + url: '/api/v1/{{.ModuleName}}', + method: 'delete', + data: { ids: ids.map(Number) } + }) +} From e2b6ddb290f7df1e2f871f3588565e590a223ebf Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 13:56:34 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat=E2=9C=A8:=20generate=20per-table=20z?= =?UTF-8?q?h-CN/en-US=20language=20packs=20(PRD=20010=20F3/F9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lang-zh.go.template and lang-en.go.template, one key per column keyed by JsonField, valued from ColumnComment (R3) with a fallback to JsonField when ColumnComment is blank (G11) - both locales resolve to the same zh-CN text today since D3 rules out machine translation and go-admin-ui's parity test rejects blank values. NOActionsGen writes both files to lang/{locale}/gen/{PackageName}/ {BusinessName}.ts, two levels deep so go-admin-ui's gen-namespace.ts glob (./*/*.ts under each locale's gen/) picks them up; nesting under PackageName (rather than a flat gen/{BusinessName}.ts) avoids two tables in different packages silently overwriting each other's translations, since BusinessName has no uniqueness check. Output has to be single-quoted with no trailing comma to satisfy go-admin-ui's eslint config, which text/template's builtin `printf "%q"` does not produce, hence the small parseGenTemplate/singleQuote helper shared by the two templates. Verified end to end: generated a real table's output (including a blank-ColumnComment column, to exercise the G11 fallback), copied the two language packs into go-admin-ui and ran vue-tsc --noEmit and eslint against them - both clean, comma-dangle/quotes included. --- app/other/apis/tools/gen.go | 81 +++++++++++++++++++++++++++++++++ template/v4/lang-en.go.template | 6 +++ template/v4/lang-zh.go.template | 6 +++ 3 files changed, 93 insertions(+) create mode 100644 template/v4/lang-en.go.template create mode 100644 template/v4/lang-zh.go.template diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 7f32c0fc..7c4d298d 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -22,6 +22,29 @@ type Gen struct { api.Api } +// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The +// generated files are TypeScript, and go-admin-ui's eslint config requires +// single-quoted strings with no trailing comma (@stylistic/quotes, +// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"` +// only produces Go/JSON-style double-quoted output, so this supplies a +// single-quote equivalent instead of leaning on the builtin. +var genLangFuncs = template.FuncMap{ + "singleQuote": func(s string) string { + r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`) + return "'" + r.Replace(s) + "'" + }, +} + +// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two +// language-pack templates. template.New's name must match the file's base +// name - ParseFiles reuses the template already registered under that name +// instead of creating an unnamed second one, which is what makes Execute +// find the parsed content afterwards. +func parseGenTemplate(path string) (*template.Template, error) { + base := path[strings.LastIndex(path, "/")+1:] + return template.New(base).Funcs(genLangFuncs).ParseFiles(path) +} + func (e Gen) Preview(c *gin.Context) { e.Context = c log := e.GetLogger() @@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) { e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error())) return } + // t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under + // gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's + // gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up. + // See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3. + t8, err := parseGenTemplate("template/v4/lang-zh.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error())) + return + } + t9, err := parseGenTemplate("template/v4/lang-en.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error())) + return + } db, err := pkg.GetOrm(c) if err != nil { @@ -98,6 +137,10 @@ func (e Gen) Preview(c *gin.Context) { err = t6.Execute(&b6, tab) var b7 bytes.Buffer err = t7.Execute(&b7, tab) + var b8 bytes.Buffer + err = t8.Execute(&b8, tab) + var b9 bytes.Buffer + err = t9.Execute(&b9, tab) mp := make(map[string]interface{}) mp["template/model.go.template"] = b1.String() @@ -107,6 +150,8 @@ func (e Gen) Preview(c *gin.Context) { mp["template/router.go.template"] = b5.String() mp["template/dto.go.template"] = b6.String() mp["template/service.go.template"] = b7.String() + mp["template/lang-zh.go.template"] = b8.String() + mp["template/lang-en.go.template"] = b9.String() e.OK(mp, "") } @@ -215,6 +260,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error())) return } + // t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above. + t8, err := parseGenTemplate(basePath + "lang-zh.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error())) + return + } + t9, err := parseGenTemplate(basePath + "lang-en.go.template") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error())) + return + } _ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/") _ = pkg.PathCreate("./app/" + tab.PackageName + "/models/") @@ -227,6 +285,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error())) return } + // gen/{PackageName}/ nests under each locale so go-admin-ui's + // gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file + // up - a flat gen/{BusinessName}.ts would let two tables in different + // packages silently overwrite each other's translations, since + // BusinessName only has a pattern check, no uniqueness check. + err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error())) + return + } + err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/") + if err != nil { + log.Error(err) + e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error())) + return + } var b1 bytes.Buffer err = t1.Execute(&b1, tab) @@ -242,6 +317,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { err = t6.Execute(&b6, tab) var b7 bytes.Buffer err = t7.Execute(&b7, tab) + var b8 bytes.Buffer + err = t8.Execute(&b8, tab) + var b9 bytes.Buffer + err = t9.Execute(&b9, tab) pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go") pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go") pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go") @@ -249,6 +328,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue") pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go") pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go") + pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts") + pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts") } diff --git a/template/v4/lang-en.go.template b/template/v4/lang-en.go.template new file mode 100644 index 00000000..2d3ff67b --- /dev/null +++ b/template/v4/lang-en.go.template @@ -0,0 +1,6 @@ +export default { +{{- range $i, $col := .Columns}} +{{- if $i}},{{end}} + {{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}} +{{- end}} +} diff --git a/template/v4/lang-zh.go.template b/template/v4/lang-zh.go.template new file mode 100644 index 00000000..2d3ff67b --- /dev/null +++ b/template/v4/lang-zh.go.template @@ -0,0 +1,6 @@ +export default { +{{- range $i, $col := .Columns}} +{{- if $i}},{{end}} + {{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}} +{{- end}} +} From 0494a27d6cc1c841237f9cc875412915c39276a1 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 13:59:27 +0800 Subject: [PATCH 04/18] =?UTF-8?q?fix=F0=9F=90=9B:=20SysColumns.Update=20ca?= =?UTF-8?q?n=20now=20clear=20colWidth/defaultValue=20back=20to=200/""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates(&e) uses GORM's struct form, which skips zero-value fields - but 0/"" is exactly the sentinel PRD 010 F1/F2 chose for "unconfigured" (数据库变更.md §1.1). A caller resetting colWidth or defaultValue back to that sentinel was therefore silently ignored: the row kept its old value, the API reported success, and reopening the edit form showed the stale number/string again. Not fixed by switching to Select("*") - that would also start writing this struct's other zero-valued fields (Sort, the Pk/Required/... bools), none of which are part of this change. A second, map-form Updates scoped to just these two columns writes the sentinel without touching anything else; map-form Updates does not skip zero values. TestSysColumnsUpdateClearsSentinelFields pins the regression: seed 150/"active", update to 0/"", read back and assert it stuck. Confirmed red before this change (got 150/"active" back) and green after. --- app/other/models/tools/sys_columns.go | 18 +++++++++ app/other/models/tools/sys_columns_test.go | 46 ++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 app/other/models/tools/sys_columns_test.go diff --git a/app/other/models/tools/sys_columns.go b/app/other/models/tools/sys_columns.go index 41db6176..e901cbfa 100644 --- a/app/other/models/tools/sys_columns.go +++ b/app/other/models/tools/sys_columns.go @@ -110,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) { return } + // Updates(&e) above skips zero-value fields (GORM's struct-form Updates + // always does), but ColWidth/DefaultValue's own "unconfigured" sentinel + // is 0/"" (see the field comments on SysColumns) - so clearing either one + // back to its sentinel is indistinguishable, to a struct-form Updates, + // from "the caller didn't touch this field" and silently does not get + // written. A map-form Updates does not skip zero values, so it is used + // here for just these two columns rather than widening this to + // Select("*") (which would also start writing every other zero-valued + // field on this struct - Sort, the Pk/Required/... bools - and that is a + // pre-existing gap in this method affecting fields outside PRD 010's + // scope, not fixed here). + if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{ + "col_width": e.ColWidth, + "default_value": e.DefaultValue, + }).Error; err != nil { + return + } + return } diff --git a/app/other/models/tools/sys_columns_test.go b/app/other/models/tools/sys_columns_test.go new file mode 100644 index 00000000..121a6cbf --- /dev/null +++ b/app/other/models/tools/sys_columns_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 / +// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/ +// 数据库变更.md §1.1). Put those together and Update can set ColWidth/ +// DefaultValue but never clear them back to the sentinel: the struct-form +// Updates call silently drops the very values this feature needs to write. +func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(new(SysColumns)); err != nil { + t.Fatalf("migrate sys_columns: %v", err) + } + + col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"} + if _, err := col.Create(db); err != nil { + t.Fatalf("create: %v", err) + } + + // Reset back to the sentinel - the UI action for "go back to inferred + // width / no default", not merely "never configured". + update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""} + if _, err := update.Update(db); err != nil { + t.Fatalf("update: %v", err) + } + + var got SysColumns + if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil { + t.Fatalf("read back: %v", err) + } + if got.ColWidth != 0 { + t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth) + } + if got.DefaultValue != "" { + t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue) + } +} From 0f31feae6f36bd7c767225b88facd6577912316b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 14:04:29 +0800 Subject: [PATCH 05/18] =?UTF-8?q?feat=E2=9C=A8:=20rewrite=20the=20generato?= =?UTF-8?q?r's=20Vue=20template=20for=20Vue=203=20(PRD=20010=20F4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit template/v4/vue.go.template produced Vue 2 syntax -- slot-scope, .sync, .native -- all removed outright in Vue 3, so every generated page failed to render (PRD 010 G1). Rewritten from scratch to match go-admin-ui's reference page (src/views/demo/product/index.vue): PageContainer + ProTable + useTable/useForm/useRemove, From d65b21baf6b05d1f8f922a080ba02fbe327eba77 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 14:07:39 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat=E2=9C=A8:=20field-level=20validation?= =?UTF-8?q?=20for=20sys=5Ftables.go:357's=20Update=20(PRD=20010=20F10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That bind-and-save path had zero field-level validation (API契约.md §1.2/§2.1, decision D6), which left four ways to silently corrupt the generator's own metadata or the language packs it writes: - jsonField had no format rule at all, unlike tableName/className/ businessName, which all have a pattern check. A jsonField with a dot, a leading digit, or anything not matching lower camelCase still saved, and would land verbatim as a key segment in gen/{PackageName}/{BusinessName}.ts. - jsonField had no per-table uniqueness check. Two columns retitled to the same jsonField overwrite each other's generated i18n key with no warning. - businessName had no cross-table uniqueness check within a packageName. Two tables sharing one write the same language pack path and the second's generation silently clobbers the first's. - defaultValue is spliced into the generated defaultModel() as a literal, never evaluated (API契约.md §2.1) - so content shaped like a function call or block does not do what it looks like it does, and previously saved without complaint either way. colWidth is handled differently on purpose: API契约.md §2.1 says an out-of-range value should fall back to the inferred width rather than be rejected, so it is reset to the 0 sentinel in place instead of failing the request - same outcome as if it had never been set. G10 (colliding with the built-in admin/* i18n namespace) is not one of the four: D9 already moved generated keys into their own gen/ namespace, so that collision no longer exists. All four checks and colWidth's sanitize-in-place path are covered by sys_tables_validate_test.go. Confirmed red first: swapped in a no-op stand-in for both validators and reran - 13 sub-tests that should now be rejected or sanitized passed straight through instead (jsonField format x5, jsonField uniqueness x1, colWidth range x2, defaultValue expression x4, businessName uniqueness x1). Restored the real implementation and reran - all green. --- app/other/apis/tools/sys_tables.go | 15 ++ app/other/apis/tools/sys_tables_validate.go | 92 +++++++++++ .../apis/tools/sys_tables_validate_test.go | 149 ++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 app/other/apis/tools/sys_tables_validate.go create mode 100644 app/other/apis/tools/sys_tables_validate_test.go diff --git a/app/other/apis/tools/sys_tables.go b/app/other/apis/tools/sys_tables.go index 734f3d13..e8c84fea 100644 --- a/app/other/apis/tools/sys_tables.go +++ b/app/other/apis/tools/sys_tables.go @@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) { return } + // PRD 010 F10: this bind-and-save path has no field-level validation of + // its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for + // what each check guards and why colWidth is sanitized in place rather + // than rejected. + if err = validateAndSanitizeColumns(data.Columns); err != nil { + log.Errorf("validate columns error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil { + log.Errorf("validate businessName error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + data.UpdateBy = 0 result, err := data.Update(db) if err != nil { diff --git a/app/other/apis/tools/sys_tables_validate.go b/app/other/apis/tools/sys_tables_validate.go new file mode 100644 index 00000000..c9353b32 --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate.go @@ -0,0 +1,92 @@ +package tools + +import ( + "fmt" + "regexp" + "strings" + + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +// jsonFieldPattern mirrors genInfoForm.vue's businessName rule +// (`/^[a-z][A-Za-z]+$/`) - jsonField has never had a format rule of its own, +// unlike businessName/tableName/className, and API契约.md §1.2 recommends +// tightening it to the same identifier shape the other three already use. +var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z]+$`) + +// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth. +const ( + colWidthMin = 40 + colWidthMax = 800 +) + +// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1 +// says defaultValue must not carry: it is spliced into the generated +// defaultModel() as a literal and never evaluated, so anything that looks +// like a function call or a block is rejected outright rather than +// generating code that silently does nothing. +var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"} + +// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by +// a table update (sys_tables.go:357's Update handler, the one bind-and-save +// path with no field-level validation at all - see API契约.md §1.2/§2.1, +// decision D6). +// +// jsonField and defaultValue problems reject the request outright: letting +// either through would corrupt the generated i18n file silently (a +// duplicate or malformed jsonField becomes a duplicate or invalid key in +// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates). +// An out-of-range colWidth does not reject - §2.1 says it "falls back to +// the inferred value", so this resets it to the 0 sentinel in place and lets +// R2's inference take over, the same as if the field had never been set. +func validateAndSanitizeColumns(columns []tools.SysColumns) error { + seen := make(map[string]bool, len(columns)) + for i := range columns { + col := &columns[i] + + if !jsonFieldPattern.MatchString(col.JsonField) { + return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField) + } + if seen[col.JsonField] { + return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField) + } + seen[col.JsonField] = true + + if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) { + col.ColWidth = 0 + } + + for _, marker := range expressionMarkers { + if strings.Contains(col.DefaultValue, marker) { + return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue) + } + } + } + return nil +} + +// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables +// sharing (packageName, businessName) write the same generated language +// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's +// NOActionsGen), so the second one silently overwrites the first's +// translations. tableID excludes the row being saved, so a table updating +// its own unchanged name does not trip the check on itself. +// +// G10's other concern - colliding with the built-in admin/* i18n namespace - +// does not apply here anymore: D9 moved generated keys to their own gen/ +// namespace, so this only has to guard generated tables against each other. +func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error { + var count int64 + err := db.Table("sys_tables"). + Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID). + Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName) + } + return nil +} diff --git a/app/other/apis/tools/sys_tables_validate_test.go b/app/other/apis/tools/sys_tables_validate_test.go new file mode 100644 index 00000000..f2a31c77 --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate_test.go @@ -0,0 +1,149 @@ +package tools + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) { + cases := []struct { + name string + jsonField string + wantErr bool + }{ + {"lower camelCase", "userName", false}, + {"two-letter lowercase", "id", false}, + {"leading underscore rejected", "_id", true}, + {"leading digit rejected", "1name", true}, + {"snake_case rejected", "user_name", true}, + {"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true}, + {"empty rejected", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}}) + if tc.wantErr && err == nil { + t.Errorf("jsonField %q: want error, got nil", tc.jsonField) + } + if !tc.wantErr && err != nil { + t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err) + } + }) + } +} + +func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{ + {JsonField: "name"}, + {JsonField: "name"}, + }) + if err == nil { + t.Fatal("want error for a jsonField repeated in the same table, got nil") + } +} + +func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) { + cases := []struct { + name string + width int + want int + }{ + {"zero (unconfigured) is left alone", 0, 0}, + {"in range is left alone", 150, 150}, + {"lower bound is left alone", colWidthMin, colWidthMin}, + {"upper bound is left alone", colWidthMax, colWidthMax}, + {"too small falls back to the sentinel", colWidthMin - 1, 0}, + {"too large falls back to the sentinel", colWidthMax + 1, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}} + if err := validateAndSanitizeColumns(cols); err != nil { + t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err) + } + if cols[0].ColWidth != tc.want { + t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth) + } + }) + } +} + +func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) { + cases := []struct { + name string + defaultValue string + wantErr bool + }{ + {"plain literal", "0", false}, + {"plain string literal", "active", false}, + {"empty (unconfigured)", "", false}, + {"function call rejected", "Date.now()", true}, + {"template literal rejected", "`x`", true}, + {"arrow function rejected", "() => 1", true}, + {"statement separator rejected", "1; drop", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}}) + if tc.wantErr && err == nil { + t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue) + } + if !tc.wantErr && err != nil { + t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err) + } + }) + } +} + +func newBusinessNameTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(new(tools.SysTables)); err != nil { + t.Fatalf("migrate sys_tables: %v", err) + } + return db +} + +func TestValidateBusinessNameUnique(t *testing.T) { + db := newBusinessNameTestDB(t) + + existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&existing).Error; err != nil { + t.Fatalf("seed: %v", err) + } + + t.Run("same package, same businessName, different table: rejected", func(t *testing.T) { + other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&other).Error; err != nil { + t.Fatalf("seed second row: %v", err) + } + // Unscoped: a plain Delete only soft-deletes (SysTables carries + // common.ModelTime), which would leave this row's businessName + // looking taken for the next subtest - production's own delete path + // (SysTables.BatchDelete) hard-deletes for the same reason. + defer db.Table("sys_tables").Unscoped().Delete(&other) + + if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil { + t.Error("want error for a businessName already used by another table in the same package, got nil") + } + }) + + t.Run("different package, same businessName: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil { + t.Errorf("want no error across different packages, got %v", err) + } + }) + + t.Run("a table checking against its own current name: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil { + t.Errorf("want no error when the only match is the row being saved itself, got %v", err) + } + }) +} From c3d2a5952b9758ee38250797d4ea0667c5a6d34b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 14:12:01 +0800 Subject: [PATCH 07/18] =?UTF-8?q?fix=F0=9F=90=9B:=20getVerifyXxx's=20pk=20?= =?UTF-8?q?parameter=20type=20follows=20GoType,=20not=20always=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ts.go.template hardcoded the pk parameter as `: number`. vue.go.template (F4, landed after this file) derives $pkType per table - "number" unless the pk column's GoType is "string" (natural keys do exist; sys_tables.go gives a pk column GoType "string" whenever its ColumnType is not int-shaped), then types useForm on that same $pkType. A string-pk table therefore generated a page calling get{Class}(id: string) against an api module whose get{Class} only accepted number - a TS compile error, not a runtime bug, and exactly the kind of gap each side's own template-only verification could not see (F4 was checked against a hand-written .ts stub before this template existed; this template was checked against a numeric-pk fixture only). Copies vue.go.template's $pkType derivation verbatim so both templates agree by construction rather than by convention. Verified: generated both a numeric-pk and a string-pk table's api module, copied both into go-admin-ui and ran vue-tsc --noEmit - clean. Added a throwaway call-site file exercising getVerifyStrPk with a string and (via @ts-expect-error) confirming a number argument is now rejected, so the type is actually enforced rather than having quietly widened to any. --- template/v4/ts.go.template | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/template/v4/ts.go.template b/template/v4/ts.go.template index f6f62f65..7b1ed598 100644 --- a/template/v4/ts.go.template +++ b/template/v4/ts.go.template @@ -1,3 +1,16 @@ +{{- /* + $pkType: the primary key's TS type for get{ClassName}'s parameter. + Defaults to "number" - true for every column but string primary keys + (natural keys), which do exist (sys_tables.go:323-338 gives a primary + key column GoType "string" whenever its ColumnType is not int-shaped). + Matches vue.go.template's own $pkType derivation exactly (F4) - useForm + there is typed on the same column, and a mismatch between the two is a + TS compile error at the call site, not a runtime bug. +*/ -}} +{{- $pkType := "number" -}} +{{- range .Columns -}} + {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}} +{{- end -}} import request from '@/utils/request' import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api' @@ -43,7 +56,7 @@ export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) { }) } -export function get{{.ClassName}}({{.PkJsonField}}: number) { +export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) { return request>({ url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}}, method: 'get' From 545453c93edf1bf4523965cf17d730f700a6befc Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 14:16:37 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat=E2=9C=A8:=20infer=20column=20width?= =?UTF-8?q?=20from=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" From 5bb211afcd05c0163c50975cc5b8c849a2520337 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 15:18:29 +0800 Subject: [PATCH 09/18] =?UTF-8?q?fix=F0=9F=90=9B:=20Preview=20now=20sets?= =?UTF-8?q?=20tab.MLTBName=20before=20rendering,=20matching=20NOActionsGen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MLTBName (table_name with underscores turned to dashes, e.g. "user_profile" -> "user-profile") is a gorm:"-" field - table.Get never fills it in, the caller has to. NOActionsGen has done so since it existed; Preview never did, so every template's import path that reads it (from '@/api/{PackageName}/{MLTBName}' in vue.go.template, present in both the pre-Vue3 template and F4's rewrite) rendered with the module segment missing - "from '@/api/admin/'" - in the preview dialog only. The real generated file was always correct. Pre-existing, not introduced by this PRD, but worth fixing now: F3/F9 added two more Preview panes (the language packs) on top of an assumption - that Preview's output stands in for what NOActionsGen actually writes - that was never true for this field. Checked the rest of gen.go for the same "only set on the write path" shape; MLTBName is the only one - every other field Preview's templates read comes straight off the sys_tables/sys_columns rows table.Get already loads. Verified with a throwaway harness driving Gen.Preview through a real gin Context and sqlite-backed db, parsing the JSON response and reading back template/vue.go.template's import line. Confirmed red first (temporarily removed the added line): "from '@/api/verify010/'". Restored it: "from '@/api/verify010/verify-widget'". --- app/other/apis/tools/gen.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 49bf3caa..02701620 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -123,6 +123,14 @@ func (e Gen) Preview(c *gin.Context) { } tab, _ := table.Get(db, false) + // MLTBName (table_name with underscores turned to dashes) is a gorm:"-" + // field - table.Get never fills it in, so every template that reads it + // (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}") + // silently rendered it empty here. NOActionsGen has set this since it + // existed (see below); Preview never did, which is why the two paths + // are not interchangeable stand-ins for each other and should not be + // assumed to be. + tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) // 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) From 30bcb57f41298b8f8028cff1184a6e01cbdc5089 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 15:21:30 +0800 Subject: [PATCH 10/18] =?UTF-8?q?fix=F0=9F=90=9B:=20put=20the=20rules=20co?= =?UTF-8?q?mputed's=20comma=20at=20the=20end=20of=20the=20previous=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules block joined entries with a $first-flag comma the same way defaultQuery and defaultModel do, but on its own line rather than all on one -- so the comma for every entry but the first sat at the start of its line instead of the end of the one before it. @stylistic/comma-style requires the opposite, and pnpm lint fails on any table with two or more required insert fields (one comma is enough to trip it; a table with 0-1 required fields never renders a second entry to get it wrong). Reproduced first: rendering a four-required-field fixture reported three comma-style errors, matching what integration testing found on qa010_widget and qa010_natural. Fixed by moving the separator so it trails the previous entry instead of leading the next one: the else branch now emits ",\n " -- comma, then the newline and indent -- rather than "\n , ", so the comma lands on the line that already has content instead of opening a fresh one. Also fixed while reproducing, found by the same fixture: dictLabel was imported whenever any column had a DictType, but it is only called from the list column's dictionary branch. A table using a dictionary solely in its insert form (no such column in the list) imported dictLabel and never called it, tripping no-unused-vars. $hasDict now only gates useDict; a new $hasDictList gates dictLabel specifically. Verified against three fixtures via a throwaway go-admin-ui worktree (deleted afterwards) with hand-written API-module stubs standing in for F5: the regression fixture (four required fields, confirmed red before the fix, green after), and the two fixtures from the original F4 verification round (full branch coverage, and the all-flags-off edge case), all of which stayed green. pnpm type-check and pnpm lint both ran clean with zero errors, on Node 24.11.0 (this machine's default node is 20.19.0; the project's engines field wants >=22). --- template/v4/vue.go.template | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index c2c56c9f..72da9422 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -36,6 +36,7 @@ anything outside itself. */ -}} {{- $hasDict := false -}} +{{- $hasDictList := false -}} {{- $hasFk := false -}} {{- $hasDatetime := false -}} {{- $hasRules := false -}} @@ -43,6 +44,7 @@ {{- $pkType := "number" -}} {{- range .Columns -}} {{- if ne .DictType "" }}{{$hasDict = true}}{{end -}} + {{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}} {{- if ne .FkTableName "" }}{{$hasFk = true}}{{end -}} {{- if eq .HtmlType "datetime" }}{{$hasDatetime = true}}{{end -}} {{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}} @@ -242,8 +244,12 @@ import ProTable from '@/components/ProTable/index.vue' import DateCell from '@/components/DateCell/index.vue' {{- end}} {{- if $hasDict}} +{{- if $hasDictList}} import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables' {{- else}} +import { useTable, useForm, useRemove, useDict } from '@/composables' +{{- end}} +{{- else}} import { useTable, useForm, useRemove } from '@/composables' {{- end}} import { @@ -313,12 +319,12 @@ const { t } = useI18n() directly under the labelled field it failed to validate. */}} -const rules = computed(() => ({ - {{- $rFirst := true}} +const rules = computed(() => ({ {{$rFirst := true}} {{- range .Columns}} {{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}} {{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}} - {{if $rFirst}}{{$rFirst = false}}{{else}}, {{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }] + {{- if $rFirst}}{{$rFirst = false}}{{else}}, + {{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }] {{- end}} {{- end}} })) From 3625ce851b11f7bd70f24158521917593b3be198 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:36:24 +0800 Subject: [PATCH 11/18] =?UTF-8?q?fix=F0=9F=90=9B:=20empty=20Query=20interf?= =?UTF-8?q?ace=20for=20zero-IsQuery=20tables=20trips=20no-empty-object-typ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain display table with no search form (zero columns marked IsQuery=1) is a normal shape, not an edge case - but ts.go.template's Query interface only had a body when the range over .Columns found a match, so it rendered `export interface {ClassName}Query {}`, which @typescript-eslint/no-empty-object-type flags and pnpm lint fails on. Falls back to `export type {ClassName}Query = Record` when no column qualifies - the same answer go-admin-ui's own useTable.ts already gives this shape (`TQuery extends object = Record`), so it intersects with PageQuery the same way an empty interface would have and callers do not need to special-case a query-less table. Audited the rest of this template for the same "zero of some optional feature" shape while in here, since this is the third time a query-less/ select-less/whatever-less table has slipped through (dictLabel, DateCell, this one): - a zero-column table: cannot occur - a table without at least a primary key column cannot exist to be imported from information_schema in the first place, so .Columns is never empty here. - a table with only its primary key column: the row interface still has one property (the pk); not empty, no lint issue. - a table where every column has IsInsert=0: does not affect this template - add{Class}/update{Class} both take the full row interface unconditionally (every column, not just IsInsert ones), so there is no column-count-dependent shape to go empty here. Verified with node 24.11.0 (not the machine default): generated a zero-IsQuery table and a with-IsQuery table, copied both into go-admin-ui and ran eslint + vue-tsc --noEmit. Confirmed red first - `export interface VerifyNoQueryQuery {}` failed eslint with exactly the no-empty-object-type error. Restored the fix - clean on both, plus a throwaway call site instantiating useTable to prove the type satisfies useTable's `TQuery extends object` constraint, not just that it parses in isolation. --- template/v4/ts.go.template | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/template/v4/ts.go.template b/template/v4/ts.go.template index 7b1ed598..4387eff1 100644 --- a/template/v4/ts.go.template +++ b/template/v4/ts.go.template @@ -8,8 +8,10 @@ TS compile error at the call site, not a runtime bug. */ -}} {{- $pkType := "number" -}} +{{- $hasQuery := false -}} {{- range .Columns -}} {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}} + {{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}} {{- end -}} import request from '@/utils/request' import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api' @@ -30,6 +32,7 @@ export interface {{.ClassName}} { {{- end}} } +{{if $hasQuery -}} export interface {{.ClassName}}Query { {{- range .Columns}} {{- if eq .IsQuery "1"}} @@ -47,6 +50,21 @@ export interface {{.ClassName}}Query { {{- end}} {{- end}} } +{{- else -}} +{{- /* + No column is marked IsQuery - a plain display table with no search form + is a normal shape, not an edge case, so this still has to produce a type + useTable/list{ClassName}(query: Query & PageQuery) can use. + `export interface {ClassName}Query {}` is what naturally falls out of the + range above finding nothing to iterate, but an empty interface trips + @typescript-eslint/no-empty-object-type and fails pnpm lint. + Record is go-admin-ui's own answer to the same shape - + see composables/useTable.ts's `TQuery extends object = Record` + - and it intersects with PageQuery the same way an empty interface would + have, so callers do not have to special-case a query-less table. +*/ -}} +export type {{.ClassName}}Query = Record +{{- end}} export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) { return request>>({ From 05661e2f3e055db7211bd92912812a19388366e7 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:37:01 +0800 Subject: [PATCH 12/18] =?UTF-8?q?fix=F0=9F=90=9B:=20jsonField=20format=20c?= =?UTF-8?q?heck=20relaxed=20to=20any=20legal=20identifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonFieldPattern copied businessName's rule (^[a-z][A-Za-z]+$: at least two letters, no digits) on the theory that jsonField should tighten to the same identifier shape. That does not hold: businessName is typed by a person on genInfoForm.vue, so a strict pattern is a reasonable guardrail on human input. jsonField is computed by the importer from the column name (sys_tables.go's namelist/JsonField loop) - nobody types it, so the same pattern only rejected names the importer legitimately produces: a single-letter column ("x") or one whose last segment ends in a digit ("address2", "a1") both collapse to a single camelCase word with nothing left to re-capitalize, and both failed the old check. The blast radius is wider than "this one column can't be edited": validateAndSanitizeColumns runs over every column on every Update, so a table that merely contains one such column could not save any config change at all, including edits with nothing to do with that column. Relaxed to ^[a-z][A-Za-z0-9]*$ - any legal JS/TS identifier starting with a lowercase letter. Still rejects what has to be rejected: empty, whitespace/punctuation, and leading-digit names, since those cannot be unquoted object keys in the generated interface/lang file at all. Uniqueness and the expression-content check on defaultValue are unchanged - defaultValue is genuinely user-typed (F6's config page), so tightening it was the right call to begin with; this was the only place a human-input rule had been copied onto machine-generated data. Verified against the real import path, not hand-typed jsonField values: built a table with columns id/x/address2/a1 in a fake information_schema, ran it through the real SysTable.Insert, confirmed the importer computes exactly jsonField x/address2/a1, then submitted an update through the real SysTable.Update changing only tableComment (nothing about those columns). Confirmed red first - 500, "jsonField 格式不合法:\"x\"" - a change unrelated to any of the three columns was rejected solely because they existed on the table. Restored the fix - 200, "修改成功". --- app/other/apis/tools/sys_tables_validate.go | 30 +++++++++++++++---- .../apis/tools/sys_tables_validate_test.go | 12 ++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/other/apis/tools/sys_tables_validate.go b/app/other/apis/tools/sys_tables_validate.go index c9353b32..067f6aaa 100644 --- a/app/other/apis/tools/sys_tables_validate.go +++ b/app/other/apis/tools/sys_tables_validate.go @@ -10,11 +10,31 @@ import ( "go-admin/app/other/models/tools" ) -// jsonFieldPattern mirrors genInfoForm.vue's businessName rule -// (`/^[a-z][A-Za-z]+$/`) - jsonField has never had a format rule of its own, -// unlike businessName/tableName/className, and API契约.md §1.2 recommends -// tightening it to the same identifier shape the other three already use. -var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z]+$`) +// jsonFieldPattern accepts any legal JS/TS identifier that starts with a +// lowercase letter - not businessName's rule. +// +// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at +// least two letters and no digits), copied over on the theory that jsonField +// "should tighten to the same identifier shape". That theory does not hold: +// businessName is typed by a person on genInfoForm.vue, so a strict pattern +// is a reasonable guardrail on human input. jsonField is computed by the +// importer from the column name (sys_tables.go's namelist/JsonField loop) - +// nobody types it, so the same pattern only rejects names the importer +// legitimately produces. A one-letter column ("x") or a column ending in a +// digit ("address2", "a1") both import to a single camelCase word with no +// separators to re-capitalize, and both used to fail this check - meaning a +// table that merely contained such a column could never save any config +// again, unrelated columns included, since this check runs over every +// column on every Update. +// +// What still has to be rejected is a jsonField that cannot be a raw object +// key at all: empty, containing whitespace/punctuation, or leading with a +// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a +// digit, and this is what lands as the property name in gen.go's generated +// interface / lang file, both unquoted). Hence still anchoring on a +// lowercase letter first, but no longer requiring a second character or +// forbidding digits after it. +var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`) // colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth. const ( diff --git a/app/other/apis/tools/sys_tables_validate_test.go b/app/other/apis/tools/sys_tables_validate_test.go index f2a31c77..0d80643e 100644 --- a/app/other/apis/tools/sys_tables_validate_test.go +++ b/app/other/apis/tools/sys_tables_validate_test.go @@ -17,9 +17,17 @@ func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) { }{ {"lower camelCase", "userName", false}, {"two-letter lowercase", "id", false}, + // The importer's own output (sys_tables.go's namelist/JsonField + // loop), not made up: a single-letter column ("x"), and a column + // whose last name segment ends in a digit ("address2", "a1") both + // produce a jsonField with no separator left to re-capitalize. + // These three used to be rejected - the whole point of this fix. + {"single letter, real importer output for a column named x", "x", false}, + {"letters then a trailing digit, real importer output for address2", "address2", false}, + {"two letters then a digit, real importer output for a1", "a1", false}, {"leading underscore rejected", "_id", true}, - {"leading digit rejected", "1name", true}, - {"snake_case rejected", "user_name", true}, + {"leading digit rejected (not a legal identifier start)", "1name", true}, + {"snake_case rejected (importer never emits an underscore)", "user_name", true}, {"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true}, {"empty rejected", "", true}, } From 630e13686c65258e86cde9d9835638d5b4d877c6 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:40:29 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix=F0=9F=90=9B:=20gate=20optional=20impo?= =?UTF-8?q?rts=20on=20where=20they're=20actually=20used,=20not=20on=20raw?= =?UTF-8?q?=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every $hasX flag controlling an optional import matched "this column carries the metadata" rather than "some rendered branch actually reads it" -- necessary but not sufficient, since FkTableName/DictType lose to each other by priority (FK wins search, list and the form's select branch; the form's radio branch never checks FK at all) and a column can carry either while being neither queryable, listed, nor an insertable select/radio. $hasDatetime was the reachable case integration testing found: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime column on import regardless of IsList, because GetList's audit-column exclusion is a separate, later step editTable.vue never surfaces created_at/updated_at through anyway. Nearly every real table has both, so nearly every table imported DateCell without using it. $hasFk and $hasDict had the identical shape one level down: the per-column ref/onMounted/useDict declarations were gated on "this column has FkTableName/DictType", not on whether the column reaches a branch that reads the resulting Options ref -- an FK column used only via search (no IsList, no insert-select) still declared a Label function nothing calls, and a dict column used only in an insert radio (no IsQuery, no IsList) still would have, had the two flags controlling its import stayed as wide as the per-column check they were meant to gate. Rewrote both to a shared $dictUsed/$fkUsed condition, matching each consuming branch's own guard term for term, and split the FK block's Label function under its own IsList check -- Options can be needed for search or the form's select without List ever being true. $hasDictList already had this shape from the previous fix and needed no change. Verified with two new fixtures, rendered through the real template.Execute and checked against a throwaway go-admin-ui worktree (deleted afterwards) with hand-written API-module stubs: a bare table carrying only the standard created_at/updated_at pair -- confirmed red on DateCell before this change, green after -- and a table exercising every optional import through a path distinct from the ones the two earlier verification rounds covered (a dict column read only from an insert radio, an FK column read only from search, and a business datetime column that IS listed, so DateCell still has to import when the real thing needs it). Re-ran the three fixtures from the previous two rounds alongside these two; all five stayed green. pnpm type-check and pnpm lint both zero error, on Node 24.11.0. --- template/v4/vue.go.template | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index 72da9422..503fdc2a 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -34,6 +34,21 @@ outer-scope variable from inside a range -- a text/template feature since Go 1.11, needed here because a range body cannot otherwise leave a mark on anything outside itself. + + Each condition below must match, term for term, the condition guarding the + markup or script that actually consumes the import -- not just "this column + has a DictType/FkTableName", which is necessary but not sufficient. A column + can carry dictionary or foreign-key metadata that no rendered branch reads: + FkTableName/DictType lose to each other by priority (FK wins search, list + and the form's select branch; the form's radio branch never looks at FK at + all), and a column can carry either one while being neither queryable nor + listed nor an insertable select/radio -- created_at/updated_at are exactly + this: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime + column on import whether or not it ever reaches IsList, because GetList's + audit-column exclusion is a separate, later step. Get a term here wrong in + either direction and either an import goes unused (no-unused-vars) or a real + usage silently loses its import (a ReferenceError this template cannot see + coming, since Vue components are the last stage that runs). */ -}} {{- $hasDict := false -}} {{- $hasDictList := false -}} @@ -43,10 +58,12 @@ {{- $hasQuery := false -}} {{- $pkType := "number" -}} {{- range .Columns -}} - {{- if ne .DictType "" }}{{$hasDict = true}}{{end -}} + {{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}} + {{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}} + {{- if $dictUsed }}{{$hasDict = true}}{{end -}} {{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}} - {{- if ne .FkTableName "" }}{{$hasFk = true}}{{end -}} - {{- if eq .HtmlType "datetime" }}{{$hasDatetime = true}}{{end -}} + {{- if $fkUsed }}{{$hasFk = true}}{{end -}} + {{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}} {{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}} {{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}} {{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}} @@ -276,21 +293,25 @@ import type { {{.FkTableNameClass}} } from '@/api/{{$package}}/{{.FkTableNamePac defineOptions({ name: '{{.ClassName}}Manage' }) {{- range .Columns}} -{{- if ne .DictType ""}} +{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}} +{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}} +{{- if $dictUsed}} const { {{.DictType}}: {{.JsonField}}Options } = useDict('{{.DictType}}') {{- end}} -{{- if ne .FkTableName ""}} +{{- if $fkUsed}} const {{.JsonField}}Options = ref<{{.FkTableNameClass}}[]>([]) onMounted(async() => { const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 }) {{.JsonField}}Options.value = res.data?.list ?? [] }) +{{- if eq .IsList "1"}} const {{.JsonField}}Label = (value: unknown) => {{.JsonField}}Options.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value {{- end}} {{- end}} +{{- end}} {{- /* Every object literal below is built on one line, joined with ", " through a $first flag rather than one field per line with a trailing comma after each: From 7a52a5096408d23c699215d07dd3f6c94c8ec7c9 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:48:58 +0800 Subject: [PATCH 14/18] =?UTF-8?q?fix=F0=9F=90=9B:=20dedupe=20FK=20imports?= =?UTF-8?q?=20by=20target=20table=20and=20filter=20by=20actual=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two columns pointing at the same foreign table -- owner and approver both selecting from the same users table, say -- each triggered their own `import { listX } from ...` / `import type { X } from ...` line, which is a duplicate ES module import once both fire: TS2300. Nothing here ever asked whether a target table had already been imported by an earlier column, because nothing tracked target tables at all -- only source columns, and "one FK-configured column" was never the same thing as "one distinct target table". The same import was also gated on the column having FkTableName set, not on $fkUsed -- the condition the previous fix already applies to the const declarations that read the import. A column carrying FK metadata but reaching no query, list or insert-select branch imported a module nothing in the file references. text/template has no set to check membership in, so the dedup is a nested range: a column only imports its target if no earlier, equally-used column already claimed the same FkTableNameClass. $fkUsed is recomputed for both the outer and the inner column rather than factored out, since text/template has no way to carry a per-column value computed in one range into a second, later range over the same data. Verified with a fixture carrying three columns pointing at the same target table -- one read only from search, one only from the list, one from neither -- rendered through the real template.Execute and checked against a throwaway go-admin-ui worktree (deleted afterwards): before this change, TS2300 fired six times (the function and the type, three times over); after, exactly one import of each, and the unused third column contributes neither. Re-ran the previous rounds' fixtures alongside it; all stayed green. pnpm type-check and pnpm lint both zero error, on Node 24.11.0. --- template/v4/vue.go.template | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index 503fdc2a..789ab16b 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -273,10 +273,31 @@ import { add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}} } from '@/api/{{.PackageName}}/{{.MLTBName}}' import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}' -{{- range .Columns}} -{{- if ne .FkTableName ""}} -import { list{{.FkTableNameClass}} } from '@/api/{{$package}}/{{.FkTableNamePackage}}' -import type { {{.FkTableNameClass}} } from '@/api/{{$package}}/{{.FkTableNamePackage}}' +{{- /* + Two columns pointing at the same foreign table must not import it twice -- + "one FK-configured column" was never the same thing as "one distinct target + table", and gen.go has no concept of a table's FK targets being unique. + text/template has no set to check membership in, so the dedup is a nested + range: a column only imports its target if no earlier, equally-used column + already claimed the same FkTableNameClass. $fkUsed is repeated here (it also + guards the const declarations above) because a column with FkTableName set + but reaching none of them -- unqueried, unlisted, not an insert select -- + has nothing that would use the import either. +*/ -}} +{{- range $i, $col := .Columns}} +{{- $fkUsed := and (ne $col.FkTableName "") (or (eq $col.IsQuery "1") (eq $col.IsList "1") (and (eq $col.IsInsert "1") (eq $col.HtmlType "select"))) -}} +{{- if $fkUsed}} +{{- $alreadyImported := false -}} +{{- range $j, $prior := $.Columns}} +{{- if lt $j $i}} +{{- $priorUsed := and (ne $prior.FkTableName "") (or (eq $prior.IsQuery "1") (eq $prior.IsList "1") (and (eq $prior.IsInsert "1") (eq $prior.HtmlType "select"))) -}} +{{- if and $priorUsed (eq $prior.FkTableNameClass $col.FkTableNameClass) }}{{$alreadyImported = true}}{{end -}} +{{- end}} +{{- end}} +{{- if not $alreadyImported}} +import { list{{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}' +import type { {{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}' +{{- end}} {{- end}} {{- end}} {{- /* From 3beb00143aa2aa0b12ea1116dddabe03c775b5d6 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:49:41 +0800 Subject: [PATCH 15/18] =?UTF-8?q?fix=F0=9F=90=9B:=20give=20FK=20and=20dict?= =?UTF-8?q?=20options=20separate=20variable=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A column configured with both FkTableName and DictType -- and reaching a branch of each, e.g. required + IsQuery=1 + IsInsert=1 with HtmlType=radio -- had both blocks declare `const {JsonField}Options`: the dict branch from useDict, the FK branch from ref(). TS2451, Cannot redeclare block-scoped variable, and the page does not compile. This is reachable precisely because FkTableName and DictType do not exclude each other consistently: search, list and the form's select branch check FK first and fall back to dict, but the form's radio branch never looks at FK at all -- it was already established (the $dictUsed/$fkUsed audit two commits back) that a radio column's dict options are used regardless of whatever FkTableName says. A column that is both radio and query-or-insert-select can legitimately need both sources at once, under one shared name. Renamed to {JsonField}DictOptions and {JsonField}FkOptions and updated every consuming branch to the name that matches what it was already branching on: FK branches (search select, form select, the list column's Label function) read FkOptions; dict branches (search select, form select, form radio, the list column's dictLabel call) read DictOptions. Mechanical rename, no new conditions -- each site already knew which source it wanted from its own if/else-if. Verified with team-lead's exact repro (FkTableName + DictType + IsQuery=1 + IsInsert=1 + HtmlType=radio) rendered through the real template.Execute and checked against a throwaway go-admin-ui worktree (deleted afterwards): TS2451 fired twice before this change, zero after -- and the rendered file confirms the search select actually reads kindFkOptions (FK wins search's priority) while the insert radio reads kindDictOptions (radio never checks FK), so both sources are live, not just declared. Re-ran every fixture from every previous round alongside it; all stayed green. pnpm type-check and pnpm lint both zero error, on Node 24.11.0. --- template/v4/vue.go.template | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index 789ab16b..de42dad0 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -80,7 +80,7 @@ {{- if ne .FkTableName ""}} - + {{- else if eq .HtmlType "datetime"}} @@ -189,7 +189,7 @@ {{- if ne .FkTableName ""}} - + {{ "{{" }} dict.label {{ "}}" }} @@ -318,18 +318,18 @@ defineOptions({ name: '{{.ClassName}}Manage' }) {{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}} {{- if $dictUsed}} -const { {{.DictType}}: {{.JsonField}}Options } = useDict('{{.DictType}}') +const { {{.DictType}}: {{.JsonField}}DictOptions } = useDict('{{.DictType}}') {{- end}} {{- if $fkUsed}} -const {{.JsonField}}Options = ref<{{.FkTableNameClass}}[]>([]) +const {{.JsonField}}FkOptions = ref<{{.FkTableNameClass}}[]>([]) onMounted(async() => { const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 }) - {{.JsonField}}Options.value = res.data?.list ?? [] + {{.JsonField}}FkOptions.value = res.data?.list ?? [] }) {{- if eq .IsList "1"}} const {{.JsonField}}Label = (value: unknown) => - {{.JsonField}}Options.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value + {{.JsonField}}FkOptions.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value {{- end}} {{- end}} {{- end}} From f6bd306d6d6976349abdd8095c109b7011e0e803 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 20:50:13 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix=F0=9F=90=9B:=20send=20datetime=20fiel?= =?UTF-8?q?ds=20as=20RFC3339,=20not=20space-separated=20local=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both date-pickers -- the search filter and the insert/edit form -- used value-format="YYYY-MM-DD HH:mm:ss", which formats a picked instant as e.g. "2026-09-19 12:30:00": no T separator, no offset. dto.go.template declares every datetime column's InsertReq/UpdateReq field as time.Time with a plain `json:"..."` tag (R6 leaves that file alone, so there is no time_format tag to reach for instead), and encoding/json's default (Un)MarshalJSON for time.Time only accepts RFC3339. The generated form would submit new and edited datetime values in a shape Go's JSON decoder cannot parse -- a runtime failure on every create and update, with nothing in `pnpm type-check` or `pnpm lint` positioned to see it: both check the request is well-typed TypeScript, not that the string it produces is a string Go can read. Changed both to value-format="YYYY-MM-DDTHH:mm:ssZ" -- dayjs's Z token renders the picker's own local offset, which is what a zero-nanosecond time.Time (anything without a database column storing sub-second precision) round-trips to on either side of the wire; confirmed separately against Go's actual json.Marshal/Unmarshal, not assumed from the RFC. The search filter needed the same fix, not just the form: GetPageReq binds a `time.Time` query field via `form:"..."` (dto.go.template), and gin's own default for an untagged time.Time binding is also RFC3339 -- the same failure mode on the query side, one call the report didn't name but the same root cause reaches. This is a runtime behaviour change no compiler catches, so it was verified as one: a Go program exercising encoding/json directly (not assumed from reading the RFC) confirmed a zero-nanosecond time.Time marshals to plain RFC3339 with no fractional seconds, and unmarshals correctly from both a numeric offset and a literal Z. Separately, a Node script loaded go-admin-ui's own installed dayjs 1.11.21 with the customParseFormat plugin -- the same plugin element-plus's date-picker extends dayjs with -- and called the same parseDate path date-picker panel.mjs uses (time-picker/src/utils.ts, no strict flag passed, so lenient parsing): formatting with this value-format produced a valid submission string, and parsing either an offset or a literal-Z string back with the same format produced a valid, correctly-valued date -- covering create, edit prefill, and update in the two directions that matter (browser to Go, Go to browser) without needing a live backend. --- template/v4/vue.go.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index de42dad0..f40e3b10 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -99,7 +99,7 @@ {{- else}} @@ -223,7 +223,7 @@ {{- else if eq .HtmlType "textarea"}} From f57bf5d61dfdfcac44a32894bc18d1b4c8165d47 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 21:20:41 +0800 Subject: [PATCH 17/18] =?UTF-8?q?fix=F0=9F=90=9B:=20query-less=20table's?= =?UTF-8?q?=20Query=20type=20broke=20FK=20dropdown=20fetches=20(P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3625ce8 fixed the no-empty-object-type lint error by switching a zero-IsQuery table's {ClassName}Query to `Record`, the same default useTable.ts's own `TQuery extends object = Record` uses. That default is safe there only because useTable.ts's one internal `TQuery & PageQuery` goes through an `as` cast rather than a structural check. Code that builds the object literal directly does not get that protection - and vue.go.template's foreign-key dropdown fetch does exactly that: `list{FkClass}({ pageIndex: 1, pageSize: 100 })`. Record is a mapped type over every string key, each mapped to never, so `Record & PageQuery` does not leave PageQuery's own properties alone: pageIndex becomes `never & number`, i.e. never, and no value can be passed for it - a straight type error, not a lint warning, so the previous fix's pnpm lint pass did not catch it. Trips on any query-less table referenced by a foreign key - a plain lookup/dict table used as a dropdown source, an ordinary shape, not a rare one. Switched to Record: a mapped type over the empty key set, which behaves as the empty object type `{}` under intersection (PageQuery's properties come through unchanged) while still satisfying `TQuery extends object` and not tripping no-empty-object-type (it is a generic instantiation, not a literal `{}` type annotation) - confirmed all three separately before touching the template. Verified with node 24.11.0: generated a real zero-IsQuery table, copied its .ts into go-admin-ui alongside a throwaway file reproducing vue.go.template's exact FK call site (`await list{Class}({ pageIndex: 1, pageSize: 100 })`), and ran both eslint and vue-tsc --noEmit - the type-check step lint alone cannot cover, which is what let this through the first time. Confirmed red first (swapped the generated file's Record back to Record): vue-tsc reported the exact "Property 'pageIndex' is incompatible with index signature" error. Restored the fix - both clean. --- template/v4/ts.go.template | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/template/v4/ts.go.template b/template/v4/ts.go.template index 4387eff1..d5e70a0f 100644 --- a/template/v4/ts.go.template +++ b/template/v4/ts.go.template @@ -58,12 +58,31 @@ export interface {{.ClassName}}Query { `export interface {ClassName}Query {}` is what naturally falls out of the range above finding nothing to iterate, but an empty interface trips @typescript-eslint/no-empty-object-type and fails pnpm lint. - Record is go-admin-ui's own answer to the same shape - - see composables/useTable.ts's `TQuery extends object = Record` - - and it intersects with PageQuery the same way an empty interface would - have, so callers do not have to special-case a query-less table. + + Record (this file's first attempt, and the type + useTable.ts's own `TQuery extends object = Record` default + uses) looks like the obvious match but is wrong here: it is a mapped type + over *every* string key, each mapped to never, so intersecting it with + PageQuery does not leave PageQuery alone - `pageIndex` becomes + `never & number`, i.e. never, and no value can be passed for it at all. + useTable.ts itself never hits this because its one internal use of + `TQuery & PageQuery` goes through an `as` cast rather than a structural + check (composables/useTable.ts ~line 160); code that builds the object + literal directly - such as a foreign-key column's + `list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template - + is not casting anything and hits the real error, only when the referenced + table happens to have no query columns of its own (a plain lookup/dict + table used as a dropdown source, not a rare shape). + + Record is the type with the same intent - "no query + columns" - but the mapped-type domain is `never`, so it has no keys at + all rather than "every key, mapped to never": it behaves as the empty + object type `{}` under intersection, leaving PageQuery's own pageIndex/ + pageSize untouched, and confirmed separately not to trip + no-empty-object-type either (it is a generic instantiation, not a + literal `{}` type annotation). */ -}} -export type {{.ClassName}}Query = Record +export type {{.ClassName}}Query = Record {{- end}} export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) { From 08f789737f55ba3a73803c2d07cb40b506f2f002 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sat, 19 Sep 2026 21:21:01 +0800 Subject: [PATCH 18/18] =?UTF-8?q?fix=F0=9F=90=9B:=20escape=20the=20literal?= =?UTF-8?q?=20T=20in=20the=20datetime=20value-format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit value-format="YYYY-MM-DDTHH:mm:ssZ" works today only because dayjs does not currently give T a format-token meaning, so it passes through as a literal character -- an accident of the current token table, not something this string declares. Escaped it to YYYY-MM-DD[T]HH:mm:ssZ, dayjs's own syntax for "this character, verbatim, not a token": produces byte-for-byte the same output today (confirmed below) and stops depending on T staying meaningless in a future dayjs version. Re-verified both directions against the escaped string, and did so against the real el-date-picker component this time rather than dayjs alone: mounted element-plus's actual ElDatePicker with value-format="YYYY-MM-DD[T]HH:mm:ssZ" (copied from a real rendering of this fixed template, not retyped) and confirmed it renders a real, non-blank date -- not "Invalid Date" -- when its modelValue is set to either shape Go's encoding/json actually sends (a numeric offset or a literal Z for UTC), and that both render identically since they are the same instant. Submission was re-checked through the same dayjs call the component itself makes to format a picked value. A fourth check formats the same instant with both the old and the escaped string and asserts they are equal, so this suite would have caught the difference if the escape had changed anything instead of just hardening it. --- template/v4/vue.go.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/v4/vue.go.template b/template/v4/vue.go.template index f40e3b10..2b1e43f1 100644 --- a/template/v4/vue.go.template +++ b/template/v4/vue.go.template @@ -99,7 +99,7 @@ {{- else}} @@ -223,7 +223,7 @@ {{- else if eq .HtmlType "textarea"}}