961 lines
32 KiB
Go
961 lines
32 KiB
Go
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
|
||
}
|