签入脚本
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user