Files
EasyGoAdmin_GoFrame_NaiveVue/internal/logic/generator.go
T
2026-10-20 19:08:49 +08:00

1002 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// +======================================================================
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
// +----------------------------------------------------------------------
// | 开源协议: Apache-2.0
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
// +----------------------------------------------------------------------
// | 官方网站: https://www.easygoadmin.vip
// +----------------------------------------------------------------------
// | 研发团队: 小蚂蚁云团队 荣誉出品
// +======================================================================
// | 版权和免责声明:
// | 一、知识产权声明
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
// | 二、使用限制与禁止条款
// | 您理解并同意,禁止将本产品用于以下任何行为:
// | 1. 任何违反中华人民共和国法律法规的用途;
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
// | 三、责任豁免
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
// | 四、违约与法律责任
// | 如发现任何违反本声明的行为,本团队有权:
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
// | 3. 保留进一步采取刑事控告等法律措施的权利。
// | 五、许可范围说明
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
// | 成的一切经济损失及商誉损害。
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
// | 许可协议》附件为准。
// +======================================================================
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
}