签入脚本

This commit is contained in:
小蚂蚁云
2026-10-20 11:37:25 +08:00
commit 7f5e8470af
909 changed files with 4045080 additions and 0 deletions
+960
View File
@@ -0,0 +1,960 @@
package logic
import (
"context"
"fmt"
"go/format"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"easygoadmin/api/v1"
"easygoadmin/internal/consts"
"easygoadmin/internal/service"
)
// ============================== 代码生成业务实现 ==============================
// sGenerator 代码生成业务实现
// 实现 service.IGenerator 接口,承载代码生成模块的业务逻辑
type sGenerator struct{}
func init() {
service.RegisterGenerator(&sGenerator{})
}
// ============================================================================
// 模板数据模型:后端三模板(api/controller/logic)与前端 ui/ui2 模板共用。
// 模板语法:Go text/template({{.Var}} / {{range}} / {{if}})。
// ============================================================================
// genChoice 枚举选项
// 从字段注释解析,例如 "状态:1-正常 2-禁用"
type genChoice struct {
Label string `json:"label"` // 选项显示名
Value string `json:"value"` // 选项值
Type string `json:"type"` // 标签类型:number / string
ValueType string `json:"valueType"` // TS 类型注释
}
// genField 单字段
// 驱动前后端模板的字段循环
type genField struct {
Name string // 数据库列名(snake)
CamelName string // JS 驼峰(parentId)
CamelExport string // Go 导出名(ParentId)
CamelLower string // 首字母小写驼峰(供 filtered 参数)
Label string // 中文名(注释清洗)
Comment string // 原始注释
Type string // 伪 Eloquent 类型(CharField/IntegerField/...)
GoType string // Go 类型(string/int/float64)
EntityGoType string // 实体与 dto 的 Go 类型(int64/*gtime.Time 等)
IsGtime bool // 时间列(entity 使用 *gtime.Time,do 同步)
RowMethod string // 行取值方法(.String()/.Int()/.Float64())
IsNumber bool // 数值类(驱动 if 判断)
Required bool // 表单必填
Default string // 表单默认值
IsImage bool // 图片字段
IsRichText bool // 富文本字段
IsTextarea bool // 多行文本字段
MinValue string // 数值控件最小值
MaxValue string // 数值控件最大值
Choices []genChoice // 枚举选项集合
HasChoices bool // 是否包含枚举选项
IsPk bool // 主键列
IsAudit bool // 审计列(不进表单与列表)
}
// genVars 模板顶层数据
type genVars struct {
AppName string // 前端 DOM 与表单名(等于模块驼峰)
ApiPath string // 前端 API 导入路径(domain/moduleCamel)
Domain string // 路由域(system/data/...)
DomainPkg string // Go 包名
DomainPkgExport string // 包名导出形(System/Data)
Module string // 模块名(snake:config_item)
ModuleCamel string // 模块驼峰(configItem)
ModelClass string // 结构体类名(ConfigItem)
TableNameFull string // 原表名(goframe_config_item)
ModuleComment string // 中文名
RoutePrefix string // 路由前缀(由模块名直接承担)
PermissionPrefix string // 权限前缀(sys:{module})
PrimaryPk string // 主键列名
DaoName string // dao 变量名(ConfigItem)
DisplayField string // 模糊搜索列
DisplayLabel string // 模糊搜索列的中文名
DisplayFieldCamel string // 模糊搜索列的驼峰名
ParentIdField string // 上级字段列名
ParentIdCamel string // 上级字段驼峰名
ParentIdCamelExport string // 上级字段导出名
OrderField string // 排序字段
OrderType string // 排序方向
TemplateType string // 模板类型:ui / ui2
IsTree bool // 是否树形结构
HasStatus bool // 是否含状态字段
HasSort bool // 是否含排序字段
HasImport bool // 是否支持导入
HasExport bool // 是否支持导出
HasCreateUser bool // 是否含创建人字段
HasCreateTime bool // 是否含创建时间字段
NeedGtime bool // 是否需要引入 gtime
HasGtime bool // 是否存在时间列
Fields []genField // 全部字段
ListFields []genField // 列表字段
FormFields []genField // 表单字段
FilterFields []genField // 筛选字段
HasImageField bool // 是否含图片字段
HasRichTextField bool // 是否含富文本字段
}
// genDomainMap 表到域的映射
// 架构文档约定的域划分,未匹配时默认 tool
var genDomainMap = map[string]string{
"user": "system", "role": "system", "menu": "system", "dept": "system",
"level": "system", "position": "system", "tenant": "system",
"config": "data", "config_item": "data", "param": "data", "dict": "data",
"dict_item": "data", "notice": "data", "city": "data",
"article": "content", "category": "content", "link": "content",
"operation_log": "monitor", "login_log": "monitor", "job": "monitor", "job_log": "monitor",
"example": "tool", "file_template": "tool", "generator": "tool", "upload": "tool",
}
// Page 数据表分页
// MySQL 查询 information_schema,其他方言使用 gdb 元数据降级
func (s *sGenerator) Page(ctx context.Context, req *v1.GeneratorPageReq) (res *v1.GeneratorPageRes, err error) {
tables, err := s.tableList(ctx)
if err != nil {
return nil, err
}
// 过滤
filtered := make([]v1.GeneratorTableItem, 0, len(tables))
for _, t := range tables {
if req.TableName != "" && !strings.Contains(strings.ToLower(t.TableName), strings.ToLower(req.TableName)) {
continue
}
if req.TableComment != "" && !strings.Contains(t.TableComment, req.TableComment) {
continue
}
filtered = append(filtered, t)
}
total := len(filtered)
start := req.Offset()
if start > total {
start = total
}
end := start + req.GetPageSize()
if end > total {
end = total
}
return v1.NewPageData(req.GetPageNo(), req.GetPageSize(), total, filtered[start:end]), nil
}
// Preview 预览代码
// 渲染模板但不落盘,返回文件清单
func (s *sGenerator) Preview(ctx context.Context, req *v1.GeneratorPreviewReq) (res *v1.GeneratorPreviewRes, err error) {
res = &v1.GeneratorPreviewRes{List: []v1.GeneratorFileItem{}}
for _, raw := range req.TableNames {
tableName := parseTableArg(raw)
files, _, e := s.renderTable(ctx, tableName)
if e != nil {
return nil, gerror.NewCodef(consts.CodeParamError, "渲染 %s 失败: %v", tableName, e)
}
for _, f := range files {
res.List = append(res.List, v1.GeneratorFileItem{Path: f.path, Content: f.content})
}
}
return res, nil
}
// Generate 一键生成
//
// 缺失文件补生成;已存在文件跳过;
// 开启 generator.overwrite 时覆盖,并同步创建菜单、字典与路由
func (s *sGenerator) Generate(ctx context.Context, req *v1.GeneratorGenerateReq) (res *v1.GeneratorGenerateRes, err error) {
res = &v1.GeneratorGenerateRes{Generated: []string{}, Skipped: []string{}, Failed: []string{}, Overwritten: []string{}, Dict: []v1.GeneratorDictResult{}}
overwrite := s.overwriteEnabled(ctx)
for _, raw := range req.TableNames {
tableName := parseTableArg(raw)
skipFiles := skipTables(tableName)
var vars *genVars
var files []genFile
var e error
if skipFiles {
// 关联表:跳过文件写入,但幂等副作用(菜单/字典/路由)照常执行
vars, e = s.buildVars(ctx, tableName)
} else {
files, vars, e = s.renderTable(ctx, tableName)
}
if e != nil {
res.Failed = append(res.Failed, fmt.Sprintf("%s: %v", tableName, e))
continue
}
if skipFiles {
res.Skipped = append(res.Skipped, tableName+"(关联表,跳过文件)")
}
for _, f := range files {
switch s.writeFile(f.path, f.content, overwrite) {
case fileWritten:
res.Generated = append(res.Generated, f.path)
case fileExists:
res.Skipped = append(res.Skipped, f.path)
case fileOverwritten:
res.Overwritten = append(res.Overwritten, f.path)
default:
res.Failed = append(res.Failed, f.path)
}
}
// 同步创建:菜单 + 字典 + 路由注册(多表时以最后一张为准合并提示)
res.Menu = s.createMenu(ctx, vars)
res.Dict = s.createDict(ctx, vars)
res.Route = s.registerRouter(vars)
}
return res, nil
}
// BatchGenerate 批量生成
// 逐表渲染并统计生成、跳过、覆盖、失败数量
func (s *sGenerator) BatchGenerate(ctx context.Context, req *v1.GeneratorBatchGenerateReq) (res *v1.GeneratorBatchGenerateRes, err error) {
res = &v1.GeneratorBatchGenerateRes{List: []v1.GeneratorBatchItem{}}
overwrite := s.overwriteEnabled(ctx)
for _, raw := range req.TableNames {
tableName := parseTableArg(raw)
item := v1.GeneratorBatchItem{Table: tableName, Success: true, Dict: []v1.GeneratorDictResult{}}
skipFiles := skipTables(tableName)
var vars *genVars
var files []genFile
var e error
if skipFiles {
vars, e = s.buildVars(ctx, tableName)
} else {
files, vars, e = s.renderTable(ctx, tableName)
}
if e != nil {
item.Success = false
item.Message = e.Error()
res.List = append(res.List, item)
continue
}
if skipFiles {
item.Message = "关联表(跳过文件)"
}
for _, f := range files {
switch s.writeFile(f.path, f.content, overwrite) {
case fileWritten:
item.Generated++
case fileExists:
item.Skipped++
case fileOverwritten:
item.Overwritten++
default:
item.Failed++
}
}
item.Menu = s.createMenu(ctx, vars)
item.Dict = s.createDict(ctx, vars)
item.Route = s.registerRouter(vars)
item.Success = item.Failed == 0
if !skipFiles {
item.Message = fmt.Sprintf("生成 %d,跳过 %d,覆盖 %d,失败 %d", item.Generated, item.Skipped, item.Overwritten, item.Failed)
}
res.List = append(res.List, item)
}
return res, nil
}
// ============================================================================
// 表结构与模板数据
// ============================================================================
// fileOutcome 文件写入结果
type fileOutcome int
const (
fileWritten fileOutcome = iota // 新写入
fileExists // 已存在并跳过
fileFailed // 写入失败
fileOverwritten // 已存在并覆盖
)
// overwriteEnabled 读取覆盖开关
//
// 唯一入口为配置 generator.overwrite;
// true 时生成一律覆盖已存在文件,false 或未配置时缺失补生成、存在跳过。
func (s *sGenerator) overwriteEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "generator.overwrite").Bool()
}
// skipTables 生成保护
//
// 仅多对多关联表整表跳过,这类表本就不应有模块文件;
// 其余表一律渲染,落盘由 writeFile 按文件粒度处理,不存在则补生成、存在则跳过、开启覆盖开关时覆盖。
func skipTables(tableName string) bool {
switch stripPrefix(tableName) {
case "role_menu", "user_role":
return true
}
return false
}
// parseTableArg 解析表参数
// 将 "表名|注释" 解析为表名
func parseTableArg(arg string) string {
if i := strings.Index(arg, "|"); i >= 0 {
return strings.TrimSpace(arg[:i])
}
return strings.TrimSpace(arg)
}
// tableList 查询数据表清单
// MySQL 走 information_schema,其他方言走 gdb 元数据降级
func (s *sGenerator) tableList(ctx context.Context) ([]v1.GeneratorTableItem, error) {
dbTypeVar, _ := g.Cfg().GetWithEnv(ctx, "database.default.type")
dbType := dbTypeVar.String()
if dbType != "mysql" {
// 非 MySQL:gdb 元数据仅提供表名(注释留空,模块名回退表名)
names, err := g.DB().Tables(ctx)
if err != nil {
return nil, err
}
items := make([]v1.GeneratorTableItem, 0, len(names))
for _, n := range names {
items = append(items, v1.GeneratorTableItem{TableName: n})
}
return items, nil
}
linkVar, _ := g.Cfg().GetWithEnv(ctx, "database.default.link")
dbName := linkVar.String()
// link 形如 mysql:user:pass@tcp(host:port)/dbname?charset=...
if i := strings.Index(dbName, "/"); i >= 0 {
dbName = dbName[i+1:]
}
if i := strings.IndexAny(dbName, "?"); i >= 0 {
dbName = dbName[:i]
}
rows, err := g.DB().GetAll(ctx,
"SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment, ENGINE AS engine,"+
" TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, AUTO_INCREMENT AS auto_increment,"+
" TABLE_COLLATION AS table_collation, CREATE_TIME AS create_time"+
" FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
dbName,
)
if err != nil {
return nil, err
}
items := make([]v1.GeneratorTableItem, 0, len(rows))
for _, r := range rows {
items = append(items, v1.GeneratorTableItem{
TableName: r["table_name"].String(),
TableComment: r["table_comment"].String(),
Engine: r["engine"].String(),
TableRows: r["table_rows"].Int64(),
DataLength: r["data_length"].Int64(),
AutoIncrement: r["auto_increment"].Int64(),
TableCollation: r["table_collation"].String(),
CreateTime: r["create_time"].GTime().String(),
})
}
return items, nil
}
// renderTable 渲染单张表的全部目标文件
// 返回渲染结果与模板变量,供副作用同步使用
type genFile struct{ path, content string }
func (s *sGenerator) renderTable(ctx context.Context, tableName string) ([]genFile, *genVars, error) {
vars, err := s.buildVars(ctx, tableName)
if err != nil {
return nil, nil, err
}
// 目标文件规划:后端三件套 + 前端(ui 普通列表 / ui2 树形)
type tplTarget struct {
tpl string
path string
}
// 后端平铺:api/v1、controller、logic、service 均不分域目录
// dao 四件套(dao/index + dao/internal + entity + do)由生成器直接落盘
targets := []tplTarget{
{"templates/api.tpl", filepath.Join("api", "v1", vars.Module+".go")},
{"templates/controller.tpl", filepath.Join("internal", "controller", vars.Module+".go")},
{"templates/logic.tpl", filepath.Join("internal", "logic", vars.Module+".go")},
{"templates/service.tpl", filepath.Join("internal", "service", vars.Module+".go")},
{"templates/dao_index.tpl", filepath.Join("internal", "dao", vars.Module+".go")},
{"templates/dao_internal.tpl", filepath.Join("internal", "dao", "internal", vars.Module+".go")},
{"templates/entity.tpl", filepath.Join("internal", "model", "entity", vars.Module+".go")},
{"templates/do.tpl", filepath.Join("internal", "model", "do", vars.Module+".go")},
}
if vars.TemplateType == "ui2" {
targets = append(targets,
tplTarget{"templates/ui2/api.ts.tpl", filepath.Join("ui", "src", "api", vars.Domain, vars.ModuleCamel+".ts")},
tplTarget{"templates/ui2/index.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "index.vue")},
tplTarget{"templates/ui2/edit.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "edit.vue")},
tplTarget{"templates/ui2/detail.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "detail.vue")},
)
} else {
targets = append(targets,
tplTarget{"templates/ui/api.ts.tpl", filepath.Join("ui", "src", "api", vars.Domain, vars.ModuleCamel+".ts")},
tplTarget{"templates/ui/index.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "index.vue")},
tplTarget{"templates/ui/edit.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "edit.vue")},
tplTarget{"templates/ui/detail.vue.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "detail.vue")},
tplTarget{"templates/ui/columns.ts.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "columns.ts")},
tplTarget{"templates/ui/querySchemas.ts.tpl", filepath.Join("ui", "src", "views", vars.Domain, vars.ModuleCamel, "querySchemas.ts")},
)
}
files := make([]genFile, 0, len(targets))
for _, t := range targets {
content, e := renderTemplate(t.tpl, vars)
if e != nil {
return nil, nil, fmt.Errorf("%s: %v", t.tpl, e)
}
// Go 产物统一 gofmt(结构体标签与注释对齐)
if strings.HasSuffix(t.path, ".go") {
if formatted, fe := format.Source([]byte(content)); fe == nil {
content = string(formatted)
} else {
return nil, nil, fmt.Errorf("%s: gofmt: %v", t.tpl, fe)
}
}
files = append(files, genFile{path: t.path, content: content})
}
return files, vars, nil
}
// renderTemplate 解析并渲染单个模板
//
// 后端模板使用 Go 默认 {{ }} 定界;
// 前端模板因与 Vue 插值 {{ }} 冲突,改用 [[ ]] 定界
func renderTemplate(tplPath string, vars *genVars) (string, error) {
raw, err := os.ReadFile(tplPath)
if err != nil {
return "", err
}
name := filepath.Base(tplPath)
tpl := template.New(name).Funcs(template.FuncMap{
// UI 模板条件辅助
"containsAny": func(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
},
"isUrlField": func(name string) bool {
switch strings.ToLower(name) {
case "website", "site", "url", "link":
return true
}
return false
},
})
if strings.HasPrefix(filepath.ToSlash(tplPath), "templates/ui") {
tpl = tpl.Delims("[[", "]]")
}
tpl, err = tpl.Parse(string(raw))
if err != nil {
return "", err
}
var sb strings.Builder
if err = tpl.ExecuteTemplate(&sb, name, vars); err != nil {
return "", err
}
return sb.String(), nil
}
// writeFile 写入文件
// 缺失时补写,已存在默认跳过以保留手工代码,overwrite 为 true 时覆盖
func (s *sGenerator) writeFile(path, content string, overwrite bool) fileOutcome {
_, statErr := os.Stat(path)
exists := statErr == nil
if exists && !overwrite {
return fileExists
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fileFailed
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fileFailed
}
if exists {
return fileOverwritten
}
return fileWritten
}
// buildVars 组装模板变量
// 读取表结构元数据,构建字段集合与命名变量
func (s *sGenerator) buildVars(ctx context.Context, tableName string) (*genVars, error) {
fieldsMap, err := g.DB().TableFields(ctx, tableName)
if err != nil {
return nil, fmt.Errorf("读取表结构失败: %v", err)
}
// 按元数据 Index 还原列顺序(map 无序)
ordered := make([]*genField, 0, len(fieldsMap))
used := make(map[string]bool, len(fieldsMap))
for i := 0; i < len(fieldsMap); i++ {
for name, f := range fieldsMap {
if !used[name] && f.Index == i {
used[name] = true
ordered = append(ordered, buildField(f))
break
}
}
}
// 兜底:Index 不连续时按名称补齐
if len(ordered) != len(fieldsMap) {
ordered = ordered[:0]
for name, f := range fieldsMap {
_ = name
ordered = append(ordered, buildField(f))
}
}
fields := ordered
moduleName := stripPrefix(tableName)
comment := tableCommentOf(ctx, tableName)
comment = cleanModuleComment(comment, moduleName)
// 字段集合
all := make([]genField, 0, len(fields))
listF := make([]genField, 0, len(fields))
formF := make([]genField, 0, len(fields))
filterF := make([]genField, 0, len(fields))
var displayField = "name"
displayCandidates := []string{"name", "title", "label", "username", "realname", "nickname",
"category_name", "job_name", "dept_name", "role_name", "position_name", "level_name"}
foundDisplay := false
hasStatus, hasSort, hasParent := false, false, false
hasImage, hasRich := false, false
hasCreateUser, hasCreateTime := false, false
hasGtime := false
for _, f := range fields {
all = append(all, *f)
if f.IsGtime {
hasGtime = true
}
switch f.Name {
case "id":
// 主键进列表(树形/分页需要 item.Id),不进表单与筛选
listF = append(listF, *f)
continue
case "create_user":
hasCreateUser = true
continue
case "create_time":
hasCreateTime = true
continue
case "update_user", "update_time", "del_flag", "tenant_id":
continue // 审计/租户列不进前后端字段
case "status":
hasStatus = true
case "sort":
hasSort = true
case "parent_id", "pid":
hasParent = true
}
// 显示字段(优先级)
if !foundDisplay {
for _, c := range displayCandidates {
if f.Name == c {
displayField = f.Name
foundDisplay = true
break
}
}
}
if f.IsRichText {
hasRich = true
}
if f.IsImage {
hasImage = true
}
// 列表:排除长文本/密码类
if !f.IsRichText && f.Name != "password" && f.Name != "salt" && f.Type != "BinaryField" {
listF = append(listF, *f)
}
// 表单:全部业务列
if f.Type != "BinaryField" {
formF = append(formF, *f)
}
// 筛选:字符串非图片/非URL → Input 逐列模糊;带枚举 → Select;外键 *_id → InputNumber;
// 排序/统计等普通数值列无查询意义,不进筛选
switch {
case f.Type == "CharField" && !f.IsImage && !isUrlFieldName(f.Name):
filterF = append(filterF, *f)
case f.HasChoices && (f.Type == "IntegerField" || f.Type == "BooleanField"):
filterF = append(filterF, *f)
case f.Type == "IntegerField" && strings.HasSuffix(f.Name, "_id"):
filterF = append(filterF, *f)
}
}
// display 必须在 list 中展示
if !foundDisplay && len(listF) > 0 {
displayField = listF[0].Name
}
// 域与命名
domain := genDomainMap[moduleName]
if domain == "" {
domain = "tool"
}
modelClass := pascal(moduleName)
moduleCamel := camelLower(moduleName)
parentIdField := "parent_id"
if hasParentField(fields, "pid") {
parentIdField = "pid"
}
vars := &genVars{
AppName: moduleCamel,
ApiPath: domain + "/" + moduleCamel,
Domain: domain,
DomainPkg: strings.ReplaceAll(domain, "_", ""),
DomainPkgExport: pascal(strings.ReplaceAll(domain, "_", "")),
Module: moduleName,
ModuleCamel: moduleCamel,
ModelClass: modelClass,
TableNameFull: tableName,
ModuleComment: comment,
RoutePrefix: moduleName,
PermissionPrefix: "sys:" + camelLower(moduleName),
PrimaryPk: "id",
DaoName: pascal(moduleName),
DisplayField: displayField,
DisplayLabel: labelOf(displayField, fields),
DisplayFieldCamel: camelLower(displayField),
ParentIdField: parentIdField,
ParentIdCamel: camelLower(parentIdField),
ParentIdCamelExport: pascal(camelLower(parentIdField)),
OrderField: orderField(hasSort),
OrderType: orderType(hasSort),
TemplateType: templateType(hasParent),
IsTree: hasParent,
HasStatus: hasStatus,
HasSort: hasSort,
HasImport: true,
HasExport: true,
HasCreateUser: hasCreateUser,
HasCreateTime: hasCreateTime,
NeedGtime: hasCreateTime,
HasGtime: hasGtime,
Fields: ptrDeref(fields),
ListFields: listF,
FormFields: formF,
FilterFields: filterF,
HasImageField: hasImage,
HasRichTextField: hasRich,
}
return vars, nil
}
// ---------- 字段构建 ----------
var (
reTinyInt1 = regexp.MustCompile(`^tinyint\(1\)`)
reInt = regexp.MustCompile(`^(tinyint|smallint|mediumint|int|bigint)\b`)
reDec = regexp.MustCompile(`^(decimal|numeric)\b`)
reFloat = regexp.MustCompile(`^(float|double|real)\b`)
reVarchar = regexp.MustCompile(`^(varchar|char|enum|set)\b`)
reText = regexp.MustCompile(`^(tinytext|text|mediumtext|longtext)\b`)
reDate = regexp.MustCompile(`^date\b`)
reDateTime = regexp.MustCompile(`^(datetime|timestamp|time)\b`)
reBlob = regexp.MustCompile(`^(tinyblob|blob|mediumblob|longblob|binary|varbinary)\b`)
reBigint = regexp.MustCompile(`^bigint\b`)
reChoice = regexp.MustCompile(`(\d+)\s*[-—-=]\s*([^\s,,;;]+)`)
)
// buildField 数据库元数据转换为模板字段
// 依据列类型推断伪 Eloquent 类型、Go 类型与行取值方法
func buildField(f *gdb.TableField) *genField {
field := &genField{
Name: f.Name,
Comment: f.Comment,
CamelName: camelLower(f.Name),
CamelExport: pascal(camelLower(f.Name)),
CamelLower: camelLower(f.Name),
Label: cleanLabel(f.Comment, f.Name),
}
switch {
case reTinyInt1.MatchString(f.Type):
field.Type = "BooleanField"
field.GoType = "int"
field.RowMethod = ".Int()"
field.IsNumber = true
case reInt.MatchString(f.Type):
field.Type = "IntegerField"
field.GoType = "int"
field.RowMethod = ".Int()"
field.IsNumber = true
case reDec.MatchString(f.Type):
field.Type = "DecimalField"
field.GoType = "float64"
field.RowMethod = ".Float64()"
field.IsNumber = true
case reFloat.MatchString(f.Type):
field.Type = "FloatField"
field.GoType = "float64"
field.RowMethod = ".Float64()"
field.IsNumber = true
case reDate.MatchString(f.Type):
field.Type = "DateField"
field.GoType = "string"
field.RowMethod = ".String()"
case reDateTime.MatchString(f.Type):
field.Type = "DateTimeField"
field.GoType = "string"
field.RowMethod = ".String()"
case reBlob.MatchString(f.Type):
field.Type = "BinaryField"
field.GoType = "string"
field.RowMethod = ".String()"
case reText.MatchString(f.Type):
field.Type = "TextField"
field.GoType = "string"
field.RowMethod = ".String()"
field.IsTextarea = true
case reVarchar.MatchString(f.Type):
field.Type = "CharField"
field.GoType = "string"
field.RowMethod = ".String()"
default:
field.Type = "CharField"
field.GoType = "string"
field.RowMethod = ".String()"
}
field.IsPk = f.Key == "PRI" || f.Name == "id"
field.IsAudit = isAuditColumn(f.Name)
field.Required = !f.Null && f.Default == nil && f.Extra != "auto_increment" && !field.IsPk
// 实体与 dto 类型映射:bigint 转 int64,时间列转 *gtime.Time,其余值类型为非指针
switch {
case reBigint.MatchString(f.Type):
field.EntityGoType = "int64"
case reDec.MatchString(f.Type), reFloat.MatchString(f.Type):
field.EntityGoType = "float64"
case reDate.MatchString(f.Type), reDateTime.MatchString(f.Type):
field.EntityGoType = "*gtime.Time"
field.IsGtime = true
case reBlob.MatchString(f.Type):
field.EntityGoType = "[]byte"
case reInt.MatchString(f.Type):
field.EntityGoType = "int"
default:
field.EntityGoType = "string"
}
// 注释解析:枚举、图片、富文本
field.Choices = parseChoices(f.Comment)
field.HasChoices = len(field.Choices) > 0
lower := strings.ToLower(f.Name)
field.IsImage = strings.Contains(f.Comment, "图片") || strings.Contains(f.Comment, "头像") ||
strings.Contains(f.Comment, "封面") || containsAny(lower, "avatar", "cover", "image", "icon", "photo")
field.IsRichText = strings.Contains(f.Comment, "富文本") ||
(field.Type == "TextField" && containsAny(lower, "content", "body", "editor"))
if field.Type == "TextField" {
field.IsTextarea = true
}
// 表单默认值与数字控件边界:数值字段默认 0,输入框 min=0 max=99999
field.Default = ""
field.MinValue = "0"
field.MaxValue = "99999"
switch field.Type {
case "IntegerField", "BooleanField", "FloatField", "DecimalField":
field.Default = "0"
}
return field
}
// isAuditColumn 判断是否为审计列
func isAuditColumn(name string) bool {
switch name {
case "id", "create_user", "create_time", "update_user", "update_time", "del_flag", "tenant_id", "salt":
return true
}
return false
}
// parseChoices 从注释解析枚举
// 例如从 "状态:1-正常 2-禁用" 解析出选项集合
func parseChoices(comment string) []genChoice {
matches := reChoice.FindAllStringSubmatch(comment, -1)
if len(matches) == 0 {
return nil
}
choices := make([]genChoice, 0, len(matches))
for _, m := range matches {
choices = append(choices, genChoice{
Label: m[2],
Value: m[1],
Type: choiceTagType(m[1]),
ValueType: "number",
})
}
return choices
}
// choiceTagType 枚举值转换为标签类型
// 1 对应 success,2 对应 danger,3 对应 warning,其余为 info
func choiceTagType(value string) string {
switch value {
case "1":
return "success"
case "2":
return "danger"
case "3":
return "warning"
default:
return "info"
}
}
// cleanLabel 字段注释转换为表单标签
// 去除枚举尾巴,保留可读的中文名
func cleanLabel(comment, name string) string {
if comment == "" {
return camelLower(name)
}
// 去掉 "状态:1-正常 2-禁用" 的枚举尾巴
if i := strings.IndexAny(comment, "::"); i > 0 {
comment = comment[:i]
}
return strings.TrimSpace(comment)
}
// cleanModuleComment 表注释转换为模块中文名
// 去除常见的表、管理、信息、数据等后缀
func cleanModuleComment(comment, fallback string) string {
comment = strings.TrimSpace(comment)
if comment == "" {
return fallback
}
comment = strings.TrimSuffix(comment, "表")
for _, suffix := range []string{"管理", "信息", "数据"} {
comment = strings.TrimSuffix(comment, suffix)
}
return comment
}
// stripPrefix 去除表名前缀
// 将 goframe_x 转换为 x
func stripPrefix(tableName string) string {
return strings.TrimPrefix(tableName, "goframe_")
}
// tableCommentOf 查询表注释
// MySQL 查询 information_schema,其他方言回退表名
func tableCommentOf(ctx context.Context, tableName string) string {
dbTypeVar, _ := g.Cfg().GetWithEnv(ctx, "database.default.type")
if dbTypeVar.String() != "mysql" {
return ""
}
row, err := g.DB().GetOne(ctx,
"SELECT TABLE_COMMENT AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", tableName)
if err != nil || len(row) == 0 {
return ""
}
return row["c"].String()
}
// templateType 根据是否树形选择模板类型
func templateType(isTree bool) string {
if isTree {
return "ui2"
}
return "ui"
}
// orderField 返回排序字段
func orderField(hasSort bool) string {
if hasSort {
return "sort"
}
return "id"
}
// orderType 返回排序方向
func orderType(hasSort bool) string {
if hasSort {
return "asc"
}
return "desc"
}
// hasParentField 判断字段集合中是否包含指定字段
func hasParentField(fields []*genField, name string) bool {
for _, f := range fields {
if f.Name == name {
return true
}
}
return false
}
// labelOf 查询字段的中文名
func labelOf(name string, fields []*genField) string {
for _, f := range fields {
if f.Name == name {
return f.Label
}
}
return name
}
// ptrDeref 指针切片转为值切片
func ptrDeref(fields []*genField) []genField {
out := make([]genField, 0, len(fields))
for _, f := range fields {
out = append(out, *f)
}
return out
}
// pascal 命名转换
// 将 snake 或 kebab 命名转换为 Pascal,例如 config_item 转换为 ConfigItem
func pascal(s string) string {
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' || r == ' ' })
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, "")
}
// camelLower 命名转换
// 将 snake 命名转换为小驼峰,例如 config_item 转换为 configItem
func camelLower(s string) string {
p := pascal(s)
if p == "" {
return p
}
return strings.ToLower(p[:1]) + p[1:]
}
// containsAny 判断字符串是否包含任一子串
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// isUrlFieldName 判断是否为 URL 或链接类列名
// 这类列存储的是地址而非可读文本,不作为模糊筛选条件
func isUrlFieldName(name string) bool {
switch strings.ToLower(name) {
case "website", "site", "url", "link":
return true
}
return false
}