980 lines
30 KiB
Go
980 lines
30 KiB
Go
package codegen
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"text/template"
|
||
|
||
codegenModel "sundynix-go/model/codegen"
|
||
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/driver/postgres"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type CodegenService struct{}
|
||
|
||
var CodegenServiceApp = new(CodegenService)
|
||
|
||
// ----------------------------------------
|
||
// 模板:model 实体(继承 global.BaseModel)
|
||
// ----------------------------------------
|
||
|
||
const modelTmpl = `package {{.PackageName}}
|
||
|
||
import "sundynix-go/global"
|
||
{{if .HasTimeField}}import "time"
|
||
{{end}}
|
||
// {{.Feature.Name}} {{.Feature.Comment}}
|
||
type {{.Feature.Name}} struct {
|
||
global.BaseModel
|
||
{{- range .Feature.Fields}}
|
||
{{.Name}} {{.Type}} ` + "`" + `gorm:"column:{{.ColumnName}}{{if .GormTag}};{{.GormTag}}{{end}}" json:"{{.JsonTag}}"` + "`" + ` // {{.Comment}}
|
||
{{- end}}
|
||
{{- range .Feature.Relations}}
|
||
{{- if eq .Type "OneToOne"}}
|
||
{{.FieldName}} *{{.TargetFeature}} ` + "`" + `gorm:"foreignKey:{{.ForeignKey}}" json:"{{lowerFirst .FieldName}}"` + "`" + `
|
||
{{- else if eq .Type "OneToMany"}}
|
||
{{.FieldName}} []{{.TargetFeature}} ` + "`" + `gorm:"foreignKey:{{.ForeignKey}}" json:"{{lowerFirst .FieldName}}"` + "`" + `
|
||
{{- else if eq .Type "ManyToMany"}}
|
||
{{.FieldName}} []{{.TargetFeature}} ` + "`" + `gorm:"many2many:{{.JoinTable}};" json:"{{lowerFirst .FieldName}}"` + "`" + `
|
||
{{- end}}
|
||
{{- end}}
|
||
}
|
||
|
||
func ({{.Feature.Name}}) TableName() string {
|
||
return "{{.Feature.TableName}}"
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:request 结构体(每个模块一个文件,包含所有实体的请求结构体)
|
||
// ----------------------------------------
|
||
|
||
const requestTmpl = `package request
|
||
{{range .Features}}
|
||
// ---- {{.Comment}} ----
|
||
|
||
// Save{{.Name}}Req 新增{{.Comment}}请求
|
||
type Save{{.Name}}Req struct {
|
||
{{- range .Fields}}
|
||
{{.Name}} {{.Type}} ` + "`" + `json:"{{.JsonTag}}" form:"{{.JsonTag}}"{{if .Required}} binding:"required"{{end}}` + "`" + ` // {{.Comment}}
|
||
{{- end}}
|
||
}
|
||
|
||
// Update{{.Name}}Req 更新{{.Comment}}请求
|
||
type Update{{.Name}}Req struct {
|
||
Id string ` + "`" + `json:"id" binding:"required"` + "`" + ` // ID
|
||
{{- range .Fields}}
|
||
{{.Name}} {{.Type}} ` + "`" + `json:"{{.JsonTag}}" form:"{{.JsonTag}}"` + "`" + ` // {{.Comment}}
|
||
{{- end}}
|
||
}
|
||
|
||
// List{{.Name}}Req 分页查询{{.Comment}}请求
|
||
type List{{.Name}}Req struct {
|
||
Current int ` + "`" + `json:"current" form:"current"` + "`" + ` // 页码
|
||
PageSize int ` + "`" + `json:"pageSize" form:"pageSize"` + "`" + ` // 每页大小
|
||
Keyword string ` + "`" + `json:"keyword" form:"keyword"` + "`" + ` // 关键字搜索
|
||
}
|
||
{{end}}`
|
||
|
||
// ----------------------------------------
|
||
// 模板:service 层(使用 request 包的结构体)
|
||
// ----------------------------------------
|
||
|
||
const serviceTmpl = `package {{.PackageName}}
|
||
|
||
import (
|
||
"sundynix-go/global"
|
||
model "sundynix-go/model/{{.PackageName}}"
|
||
req "sundynix-go/model/{{.PackageName}}/request"
|
||
)
|
||
|
||
type {{.Feature.Name}}Service struct{}
|
||
|
||
var {{.Feature.Name}}ServiceApp = new({{.Feature.Name}}Service)
|
||
|
||
func (s *{{.Feature.Name}}Service) Save(r req.Save{{.Feature.Name}}Req) error {
|
||
data := model.{{.Feature.Name}}{
|
||
{{- range .Feature.Fields}}
|
||
{{.Name}}: r.{{.Name}},
|
||
{{- end}}
|
||
}
|
||
return global.DB.Create(&data).Error
|
||
}
|
||
|
||
func (s *{{.Feature.Name}}Service) Update(r req.Update{{.Feature.Name}}Req) error {
|
||
updates := map[string]any{
|
||
{{- range .Feature.Fields}}
|
||
"{{.ColumnName}}": r.{{.Name}},
|
||
{{- end}}
|
||
}
|
||
return global.DB.Model(&model.{{.Feature.Name}}{}).Where("id = ?", r.Id).Updates(updates).Error
|
||
}
|
||
|
||
func (s *{{.Feature.Name}}Service) Delete(ids []string) error {
|
||
return global.DB.Where("id IN ?", ids).Delete(&model.{{.Feature.Name}}{}).Error
|
||
}
|
||
|
||
func (s *{{.Feature.Name}}Service) Detail(id string) (data *model.{{.Feature.Name}}, err error) {
|
||
var record model.{{.Feature.Name}}
|
||
err = global.DB.Where("id = ?", id).First(&record).Error
|
||
return &record, err
|
||
}
|
||
|
||
func (s *{{.Feature.Name}}Service) List(r req.List{{.Feature.Name}}Req) (list []model.{{.Feature.Name}}, total int64, err error) {
|
||
if r.PageSize <= 0 {
|
||
r.PageSize = 10
|
||
}
|
||
if r.Current <= 0 {
|
||
r.Current = 1
|
||
}
|
||
db := global.DB.Model(&model.{{.Feature.Name}}{})
|
||
if r.Keyword != "" {
|
||
db = db.Where("id LIKE ?", "%"+r.Keyword+"%")
|
||
}
|
||
if err = db.Count(&total).Error; err != nil {
|
||
return
|
||
}
|
||
err = db.Limit(r.PageSize).Offset(r.PageSize * (r.Current - 1)).Find(&list).Error
|
||
return
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:service enter.go
|
||
// ----------------------------------------
|
||
|
||
const serviceEnterTmpl = `package {{.PackageName}}
|
||
|
||
type ServiceGroup struct {
|
||
{{- range .Features}}
|
||
{{.Name}}Service
|
||
{{- end}}
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:api handler 层(使用 request 包的结构体)
|
||
// ----------------------------------------
|
||
|
||
const apiTmpl = `package {{.PackageName}}
|
||
|
||
import (
|
||
"sundynix-go/global"
|
||
"sundynix-go/model/commom/response"
|
||
req "sundynix-go/model/{{.PackageName}}/request"
|
||
"sundynix-go/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
type {{.Feature.Name}}Api struct{}
|
||
|
||
var {{.LowerName}}Service = service.ServiceGroupApp.{{.PascalModuleName}}ServiceGroup.{{.Feature.Name}}Service
|
||
|
||
// Save
|
||
// @Tags {{.Feature.Comment}}管理
|
||
// @Summary 新增{{.Feature.Comment}}
|
||
// @Security BasicAuth
|
||
// @accept json
|
||
// @Produce json
|
||
// @Param data body req.Save{{.Feature.Name}}Req true "新增{{.Feature.Comment}}参数"
|
||
// @Success 200 {object} response.Response "新增成功"
|
||
// @Router /{{.RouterPrefix}}/save [post]
|
||
func (a *{{.Feature.Name}}Api) Save(c *gin.Context) {
|
||
var r req.Save{{.Feature.Name}}Req
|
||
if err := c.ShouldBindJSON(&r); err != nil {
|
||
response.FailWithMsg("参数错误:"+err.Error(), c)
|
||
return
|
||
}
|
||
if err := {{.LowerName}}Service.Save(r); err != nil {
|
||
global.Logger.Error("新增{{.Feature.Comment}}失败!", zap.Error(err))
|
||
response.FailWithMsg(err.Error(), c)
|
||
return
|
||
}
|
||
response.OkWithMsg("新增成功", c)
|
||
}
|
||
|
||
// Update
|
||
// @Tags {{.Feature.Comment}}管理
|
||
// @Summary 更新{{.Feature.Comment}}
|
||
// @Security BasicAuth
|
||
// @accept json
|
||
// @Produce json
|
||
// @Param data body req.Update{{.Feature.Name}}Req true "更新{{.Feature.Comment}}参数"
|
||
// @Success 200 {object} response.Response "更新成功"
|
||
// @Router /{{.RouterPrefix}}/update [post]
|
||
func (a *{{.Feature.Name}}Api) Update(c *gin.Context) {
|
||
var r req.Update{{.Feature.Name}}Req
|
||
if err := c.ShouldBindJSON(&r); err != nil {
|
||
response.FailWithMsg("参数错误:"+err.Error(), c)
|
||
return
|
||
}
|
||
if err := {{.LowerName}}Service.Update(r); err != nil {
|
||
global.Logger.Error("更新{{.Feature.Comment}}失败!", zap.Error(err))
|
||
response.FailWithMsg(err.Error(), c)
|
||
return
|
||
}
|
||
response.OkWithMsg("更新成功", c)
|
||
}
|
||
|
||
// Delete
|
||
// @Tags {{.Feature.Comment}}管理
|
||
// @Summary 删除{{.Feature.Comment}}(支持批量)
|
||
// @Security BasicAuth
|
||
// @accept json
|
||
// @Produce json
|
||
// @Param data body object{ids=[]string} true "id列表"
|
||
// @Success 200 {object} response.Response "删除成功"
|
||
// @Router /{{.RouterPrefix}}/delete [post]
|
||
func (a *{{.Feature.Name}}Api) Delete(c *gin.Context) {
|
||
var req struct {
|
||
Ids []string ` + "`" + `json:"ids" binding:"required,min=1"` + "`" + `
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.FailWithMsg("参数错误:"+err.Error(), c)
|
||
return
|
||
}
|
||
if err := {{.LowerName}}Service.Delete(req.Ids); err != nil {
|
||
global.Logger.Error("删除{{.Feature.Comment}}失败!", zap.Error(err))
|
||
response.FailWithMsg(err.Error(), c)
|
||
return
|
||
}
|
||
response.OkWithMsg("删除成功", c)
|
||
}
|
||
|
||
// Detail
|
||
// @Tags {{.Feature.Comment}}管理
|
||
// @Summary 获取{{.Feature.Comment}}详情
|
||
// @Security BasicAuth
|
||
// @Produce json
|
||
// @Param id query string true "ID"
|
||
// @Success 200 {object} response.Response "获取详情成功"
|
||
// @Router /{{.RouterPrefix}}/detail [get]
|
||
func (a *{{.Feature.Name}}Api) Detail(c *gin.Context) {
|
||
id := c.Query("id")
|
||
data, err := {{.LowerName}}Service.Detail(id)
|
||
if err != nil {
|
||
global.Logger.Error("获取{{.Feature.Comment}}详情失败!", zap.Error(err))
|
||
response.FailWithMsg(err.Error(), c)
|
||
return
|
||
}
|
||
response.OkWithData(data, c)
|
||
}
|
||
|
||
// List
|
||
// @Tags {{.Feature.Comment}}管理
|
||
// @Summary 分页获取{{.Feature.Comment}}列表
|
||
// @Security BasicAuth
|
||
// @accept json
|
||
// @Produce json
|
||
// @Param data body req.List{{.Feature.Name}}Req true "分页参数"
|
||
// @Success 200 {object} response.Response{data=response.PageResult} "获取列表成功"
|
||
// @Router /{{.RouterPrefix}}/list [post]
|
||
func (a *{{.Feature.Name}}Api) List(c *gin.Context) {
|
||
var r req.List{{.Feature.Name}}Req
|
||
if err := c.ShouldBindJSON(&r); err != nil {
|
||
response.FailWithMsg("参数错误:"+err.Error(), c)
|
||
return
|
||
}
|
||
list, total, err := {{.LowerName}}Service.List(r)
|
||
if err != nil {
|
||
global.Logger.Error("获取{{.Feature.Comment}}列表失败!", zap.Error(err))
|
||
response.FailWithMsg(err.Error(), c)
|
||
return
|
||
}
|
||
response.OkWithData(response.PageResult{
|
||
List: list,
|
||
Total: total,
|
||
Page: r.Current,
|
||
PageSize: r.PageSize,
|
||
}, c)
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:api enter.go
|
||
// ----------------------------------------
|
||
|
||
const apiEnterTmpl = `package {{.PackageName}}
|
||
|
||
type ApiGroup struct {
|
||
{{- range .Features}}
|
||
{{.Name}}Api
|
||
{{- end}}
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:router
|
||
// ----------------------------------------
|
||
|
||
const routerTmpl = `package {{.PackageName}}
|
||
|
||
import "github.com/gin-gonic/gin"
|
||
|
||
type {{.Feature.Name}}Router struct{}
|
||
|
||
func (r *{{.Feature.Name}}Router) Init{{.Feature.Name}}Router(Router *gin.RouterGroup) {
|
||
{{.LowerName}}Router := Router.Group("{{.RouterGroup}}")
|
||
{
|
||
{{.LowerName}}Router.POST("save", {{.LowerName}}Api.Save)
|
||
{{.LowerName}}Router.POST("update", {{.LowerName}}Api.Update)
|
||
{{.LowerName}}Router.POST("delete", {{.LowerName}}Api.Delete)
|
||
{{.LowerName}}Router.GET("detail", {{.LowerName}}Api.Detail)
|
||
{{.LowerName}}Router.POST("list", {{.LowerName}}Api.List)
|
||
}
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 模板:router enter.go
|
||
// ----------------------------------------
|
||
|
||
const routerEnterTmpl = `package {{.PackageName}}
|
||
|
||
import v1 "sundynix-go/api/v1"
|
||
|
||
type RouterGroup struct {
|
||
{{- range .Features}}
|
||
{{.Name}}Router
|
||
{{- end}}
|
||
}
|
||
|
||
var (
|
||
{{- range .Features}}
|
||
{{lowerFirst .Name}}Api = v1.ApiGroupApp.{{.PascalModuleName}}ApiGroup.{{.Name}}Api
|
||
{{- end}}
|
||
)
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 前端模板:TypeScript 类型定义
|
||
// ----------------------------------------
|
||
|
||
const feTsTypeTmpl = `// {{.Module.Name}} 模块类型定义(自动生成,请勿手动修改)
|
||
import type { BaseModel } from '@/types'
|
||
{{range .Features}}
|
||
// ---- {{.Comment}} ----
|
||
|
||
/** {{.Comment}}实体 */
|
||
export interface {{.Name}} extends BaseModel {
|
||
{{- range .Fields}}
|
||
{{.JsonTag}}: {{goTypeToTs .Type}} // {{.Comment}}
|
||
{{- end}}
|
||
}
|
||
|
||
/** 新增{{.Comment}}请求 */
|
||
export interface Save{{.Name}}Req {
|
||
{{- range .Fields}}
|
||
{{.JsonTag}}{{if not .Required}}?{{end}}: {{goTypeToTs .Type}} // {{.Comment}}
|
||
{{- end}}
|
||
}
|
||
|
||
/** 更新{{.Comment}}请求 */
|
||
export interface Update{{.Name}}Req {
|
||
id: string
|
||
{{- range .Fields}}
|
||
{{.JsonTag}}?: {{goTypeToTs .Type}} // {{.Comment}}
|
||
{{- end}}
|
||
}
|
||
|
||
/** 分页查询{{.Comment}}请求 */
|
||
export interface List{{.Name}}Req {
|
||
current?: number
|
||
pageSize?: number
|
||
keyword?: string
|
||
}
|
||
{{end}}`
|
||
|
||
// ----------------------------------------
|
||
// 前端模板:API service (Axios)
|
||
// ----------------------------------------
|
||
|
||
const feApiTmpl = `import { get, post } from '@/lib/request'
|
||
import type {
|
||
{{.Feature.Name}},
|
||
Save{{.Feature.Name}}Req,
|
||
Update{{.Feature.Name}}Req,
|
||
List{{.Feature.Name}}Req,
|
||
} from '@/types/{{.Module.PackageName}}'
|
||
import type { PageResult } from '@/types'
|
||
|
||
const BASE = '/{{.Module.PackageName}}/{{.SnakeName}}'
|
||
|
||
export const {{lowerFirst .Feature.Name}}Api = {
|
||
save: (data: Save{{.Feature.Name}}Req) =>
|
||
post<void>(` + "`" + `${BASE}/save` + "`" + `, data),
|
||
|
||
update: (data: Update{{.Feature.Name}}Req) =>
|
||
post<void>(` + "`" + `${BASE}/update` + "`" + `, data),
|
||
|
||
delete: (ids: string[]) =>
|
||
post<void>(` + "`" + `${BASE}/delete` + "`" + `, { ids }),
|
||
|
||
detail: (id: string) =>
|
||
get<{{.Feature.Name}}>(` + "`" + `${BASE}/detail` + "`" + `, { id }),
|
||
|
||
list: (params: List{{.Feature.Name}}Req) =>
|
||
post<PageResult<{{.Feature.Name}}>>(` + "`" + `${BASE}/list` + "`" + `, params),
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 前端模板:React 管理页面
|
||
// ----------------------------------------
|
||
|
||
const fePageTmpl = `import { useState, useEffect } from 'react'
|
||
import { {{lowerFirst .Feature.Name}}Api } from '@/api/{{.Module.PackageName}}/{{.SnakeName}}'
|
||
import type { {{.Feature.Name}}, List{{.Feature.Name}}Req } from '@/types/{{.Module.PackageName}}'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Card, CardContent } from '@/components/ui/card'
|
||
import { toast } from 'sonner'
|
||
import { Plus, Pencil, Trash2, Search, RefreshCw } from 'lucide-react'
|
||
|
||
export default function {{.Feature.Name}}Page() {
|
||
const [list, setList] = useState<{{.Feature.Name}}[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [params, setParams] = useState<List{{.Feature.Name}}Req>({ current: 1, pageSize: 10, keyword: '' })
|
||
|
||
const load = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await {{lowerFirst .Feature.Name}}Api.list(params)
|
||
setList(res.list)
|
||
setTotal(res.total)
|
||
} catch {}
|
||
finally { setLoading(false) }
|
||
}
|
||
|
||
useEffect(() => { load() }, [params.current])
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm('确认删除?')) return
|
||
try {
|
||
await {{lowerFirst .Feature.Name}}Api.delete([id])
|
||
toast.success('删除成功')
|
||
load()
|
||
} catch {}
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / (params.pageSize ?? 10))
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-xl font-semibold">{{.Feature.Comment}}管理</h2>
|
||
<p className="text-sm text-muted-foreground">共 {total} 条记录</p>
|
||
</div>
|
||
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> 新增</Button>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardContent className="pt-4">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="关键字搜索"
|
||
value={params.keyword}
|
||
onChange={e => setParams(p => ({ ...p, keyword: e.target.value, current: 1 }))}
|
||
className="max-w-60"
|
||
/>
|
||
<Button variant="outline" size="sm" onClick={load}><Search className="h-4 w-4 mr-1" />搜索</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => setParams(p => ({ ...p, keyword: '', current: 1 }))}>
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<table className="w-full text-sm">
|
||
<thead className="border-b bg-muted/40">
|
||
<tr>
|
||
{{- range .Feature.Fields}}{{if .Comment}}
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{{.Comment}}</th>
|
||
{{- end}}{{end}}
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">创建时间</th>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr><td colSpan={ {{- .ColCount -}} } className="py-12 text-center text-muted-foreground">加载中…</td></tr>
|
||
) : list.length === 0 ? (
|
||
<tr><td colSpan={ {{- .ColCount -}} } className="py-12 text-center text-muted-foreground">暂无数据</td></tr>
|
||
) : list.map(item => (
|
||
<tr key={item.id} className="border-b last:border-b-0 hover:bg-muted/30 transition-colors">
|
||
{{- range .Feature.Fields}}{{if .JsonTag}}<td className="px-4 py-3">{String(item.{{.JsonTag}} ?? '—')}</td>{{end}}{{end}}
|
||
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString()}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1">
|
||
<Button variant="ghost" size="icon" className="h-7 w-7"><Pencil className="h-3.5 w-3.5" /></Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(item.id)}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||
<span className="text-xs text-muted-foreground">第 {params.current} / {totalPages} 页</span>
|
||
<div className="flex gap-1">
|
||
<Button variant="outline" size="sm" disabled={(params.current ?? 1) <= 1}
|
||
onClick={() => setParams(p => ({ ...p, current: (p.current ?? 1) - 1 }))}>上一页</Button>
|
||
<Button variant="outline" size="sm" disabled={(params.current ?? 1) >= totalPages}
|
||
onClick={() => setParams(p => ({ ...p, current: (p.current ?? 1) + 1 }))}>下一页</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
`
|
||
|
||
// ----------------------------------------
|
||
// 辅助函数
|
||
// ----------------------------------------
|
||
|
||
func lowerFirst(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
return strings.ToLower(s[:1]) + s[1:]
|
||
}
|
||
|
||
func hasTimeField(feature codegenModel.Feature) bool {
|
||
for _, f := range feature.Fields {
|
||
if f.Type == "time.Time" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// goTypeToTs 将 Go 类型映射为 TypeScript 类型
|
||
func goTypeToTs(goType string) string {
|
||
switch goType {
|
||
case "int", "int8", "int16", "int32", "int64",
|
||
"uint", "uint8", "uint16", "uint32", "uint64",
|
||
"float32", "float64":
|
||
return "number"
|
||
case "bool":
|
||
return "boolean"
|
||
case "time.Time":
|
||
return "string"
|
||
default:
|
||
return "string"
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------
|
||
// TestConnection 测试数据库连接
|
||
// ----------------------------------------
|
||
|
||
func (s *CodegenService) TestConnection(cfg codegenModel.DbConfig) error {
|
||
_, err := openDB(cfg)
|
||
return err
|
||
}
|
||
|
||
func openDB(cfg codegenModel.DbConfig) (*gorm.DB, error) {
|
||
switch strings.ToLower(cfg.DbType) {
|
||
case "mysql":
|
||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DbName)
|
||
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||
case "postgres", "postgresql":
|
||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
|
||
cfg.Host, cfg.User, cfg.Password, cfg.DbName, cfg.Port)
|
||
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||
case "sqlite":
|
||
return gorm.Open(sqlite.Open(cfg.DbName), &gorm.Config{})
|
||
default:
|
||
return nil, fmt.Errorf("不支持的数据库类型: %s", cfg.DbType)
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------
|
||
// Preview 预览生成代码
|
||
// ----------------------------------------
|
||
|
||
func (s *CodegenService) Preview(config codegenModel.GenConfig) ([]codegenModel.PreviewFile, error) {
|
||
backendFiles, err := s.renderAll(config, config.OutputDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if config.FrontendOutputDir != "" {
|
||
feFiles, err := s.renderFrontend(config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
backendFiles = append(backendFiles, feFiles...)
|
||
}
|
||
return backendFiles, nil
|
||
}
|
||
|
||
// ----------------------------------------
|
||
// Generate 生成并写入文件
|
||
// ----------------------------------------
|
||
|
||
func (s *CodegenService) Generate(config codegenModel.GenConfig) (*codegenModel.GenResult, error) {
|
||
outputDir := config.OutputDir
|
||
if outputDir == "" {
|
||
cwd, _ := os.Getwd()
|
||
outputDir = cwd
|
||
}
|
||
|
||
// 1. 渲染后端文件
|
||
backendFiles, err := s.renderAll(config, outputDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 2. 自动注册(必须在写入文件之前,否则 enter.go 已存在导致误判为已有模块)
|
||
regMsg := ""
|
||
if err := s.autoRegister(outputDir, config); err != nil {
|
||
regMsg = fmt.Sprintf("⚠️ 自动注册失败: %s", err.Error())
|
||
} else {
|
||
regMsg = "✅ 自动注册成功(gorm/router/enter)"
|
||
}
|
||
|
||
// 3. 写入后端文件(增量模式下跳过已存在文件)
|
||
written, skipped := writeFilesIncremental(outputDir, backendFiles, config.Overwrite)
|
||
|
||
allFiles := backendFiles
|
||
|
||
// 4. 生成前端文件(如果配置了前端输出目录)
|
||
if config.FrontendOutputDir != "" {
|
||
feFiles, err := s.renderFrontend(config)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
w, sk := writeFilesIncremental(config.FrontendOutputDir, feFiles, config.Overwrite)
|
||
written += w
|
||
skipped += sk
|
||
allFiles = append(allFiles, feFiles...)
|
||
}
|
||
|
||
return &codegenModel.GenResult{
|
||
OutputDir: outputDir,
|
||
Files: allFiles,
|
||
Message: fmt.Sprintf("写入 %d 个文件,跳过 %d 个已存在文件。%s", written, skipped, regMsg),
|
||
}, nil
|
||
}
|
||
|
||
// writeFilesIncremental 写入文件,支持增量模式
|
||
// overwrite=false 时,已存在的文件会被跳过
|
||
// 返回 (写入数, 跳过数)
|
||
func writeFilesIncremental(baseDir string, files []codegenModel.PreviewFile, overwrite bool) (written int, skipped int) {
|
||
for _, f := range files {
|
||
fullPath := filepath.Join(baseDir, f.FilePath)
|
||
// 增量模式下跳过已存在文件(但 AlwaysOverwrite 的聚合文件始终覆盖)
|
||
if !overwrite && !f.AlwaysOverwrite {
|
||
if _, err := os.Stat(fullPath); err == nil {
|
||
skipped++
|
||
continue
|
||
}
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||
continue
|
||
}
|
||
if err := os.WriteFile(fullPath, []byte(f.Content), 0644); err != nil {
|
||
continue
|
||
}
|
||
written++
|
||
}
|
||
return
|
||
}
|
||
|
||
// renderFrontend 渲染前端 TypeScript / React 文件
|
||
func (s *CodegenService) renderFrontend(config codegenModel.GenConfig) ([]codegenModel.PreviewFile, error) {
|
||
var result []codegenModel.PreviewFile
|
||
|
||
funcMap := template.FuncMap{
|
||
"lowerFirst": lowerFirst,
|
||
"goTypeToTs": goTypeToTs,
|
||
"not": func(b bool) bool { return !b },
|
||
}
|
||
|
||
for _, module := range config.Modules {
|
||
// 跳过未填写包名的模块
|
||
if strings.TrimSpace(module.PackageName) == "" || strings.TrimSpace(module.Name) == "" {
|
||
continue
|
||
}
|
||
// ---- TypeScript 类型(模块级,所有实体合并到 index.ts)----
|
||
tsData := struct {
|
||
Module codegenModel.Module
|
||
Features []codegenModel.Feature
|
||
}{module, module.Features}
|
||
tsContent, err := renderTmpl("fe_ts_type", feTsTypeTmpl, funcMap, tsData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("src", "types", module.PackageName, "index.ts"),
|
||
Content: tsContent,
|
||
AlwaysOverwrite: true,
|
||
})
|
||
|
||
for _, feature := range module.Features {
|
||
// 跳过未命名的实体
|
||
if strings.TrimSpace(feature.Name) == "" {
|
||
continue
|
||
}
|
||
snakeName := toSnake(feature.Name)
|
||
|
||
feData := struct {
|
||
Module codegenModel.Module
|
||
Feature codegenModel.Feature
|
||
SnakeName string
|
||
ColCount int
|
||
}{module, feature, snakeName, len(feature.Fields) + 2}
|
||
|
||
// API service
|
||
apiContent, err := renderTmpl("fe_api", feApiTmpl, funcMap, feData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("src", "api", module.PackageName, snakeName+".ts"),
|
||
Content: apiContent,
|
||
})
|
||
|
||
// React page
|
||
pageContent, err := renderTmpl("fe_page", fePageTmpl, funcMap, feData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("src", "pages", module.PackageName, feature.Name+"Page.tsx"),
|
||
Content: pageContent,
|
||
})
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// ----------------------------------------
|
||
// renderAll 渲染所有模块的所有文件
|
||
// ----------------------------------------
|
||
|
||
func (s *CodegenService) renderAll(config codegenModel.GenConfig, outputDir string) ([]codegenModel.PreviewFile, error) {
|
||
var result []codegenModel.PreviewFile
|
||
|
||
funcMap := template.FuncMap{
|
||
"lowerFirst": lowerFirst,
|
||
}
|
||
|
||
for _, module := range config.Modules {
|
||
// 跳过未填写包名的模块
|
||
if strings.TrimSpace(module.PackageName) == "" || strings.TrimSpace(module.Name) == "" {
|
||
continue
|
||
}
|
||
pkg := module.PackageName
|
||
pascalModule := module.Name
|
||
|
||
type featureEntry struct {
|
||
Name string
|
||
PascalModuleName string
|
||
}
|
||
|
||
// 发现已存在的 feature(扫描 service/{pkg}/ 下的 *_service.go 文件)
|
||
existingFeatures := discoverExistingFeatures(outputDir, pkg)
|
||
|
||
// 合并:已有 feature + 当前配置中的 feature(去重)
|
||
featureSet := make(map[string]bool)
|
||
var allFeatureEntries []featureEntry
|
||
for _, name := range existingFeatures {
|
||
if !featureSet[name] {
|
||
featureSet[name] = true
|
||
allFeatureEntries = append(allFeatureEntries, featureEntry{name, pascalModule})
|
||
}
|
||
}
|
||
for _, f := range module.Features {
|
||
if strings.TrimSpace(f.Name) == "" {
|
||
continue
|
||
}
|
||
if !featureSet[f.Name] {
|
||
featureSet[f.Name] = true
|
||
allFeatureEntries = append(allFeatureEntries, featureEntry{f.Name, pascalModule})
|
||
}
|
||
}
|
||
|
||
enterData := struct {
|
||
PackageName string
|
||
Features []featureEntry
|
||
}{pkg, allFeatureEntries}
|
||
|
||
// service/enter.go(始终覆盖,包含所有已有+新增 feature)
|
||
svcEnter, err := renderTmpl("service_enter", serviceEnterTmpl, funcMap, enterData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("service", pkg, "enter.go"),
|
||
Content: svcEnter,
|
||
AlwaysOverwrite: true,
|
||
})
|
||
|
||
// api/v1/enter.go
|
||
apiEnter, err := renderTmpl("api_enter", apiEnterTmpl, funcMap, enterData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("api", "v1", pkg, "enter.go"),
|
||
Content: apiEnter,
|
||
AlwaysOverwrite: true,
|
||
})
|
||
|
||
// router/enter.go
|
||
routerEnter, err := renderTmpl("router_enter", routerEnterTmpl, funcMap, enterData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("router", pkg, "enter.go"),
|
||
Content: routerEnter,
|
||
AlwaysOverwrite: true,
|
||
})
|
||
|
||
// ---- request 结构体(按 feature 分文件,增量安全)----
|
||
for _, feature := range module.Features {
|
||
if strings.TrimSpace(feature.Name) == "" {
|
||
continue
|
||
}
|
||
reqData := struct {
|
||
PackageName string
|
||
Features []codegenModel.Feature
|
||
}{pkg, []codegenModel.Feature{feature}}
|
||
reqContent, err := renderTmpl("request", requestTmpl, funcMap, reqData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("model", pkg, "request", toSnake(feature.Name)+"_request.go"),
|
||
Content: reqContent,
|
||
})
|
||
}
|
||
|
||
// 每个 Feature 生成:model / service / api / router
|
||
for _, feature := range module.Features {
|
||
// 跳过未命名的实体
|
||
if strings.TrimSpace(feature.Name) == "" {
|
||
continue
|
||
}
|
||
lower := lowerFirst(feature.Name)
|
||
snakeName := toSnake(feature.Name)
|
||
routerGroup := snakeName
|
||
routerPrefix := pkg + "/" + snakeName
|
||
|
||
// ---- model ----
|
||
modelData := struct {
|
||
PackageName string
|
||
Feature codegenModel.Feature
|
||
HasTimeField bool
|
||
}{pkg, feature, hasTimeField(feature)}
|
||
modelContent, err := renderTmpl("model", modelTmpl, funcMap, modelData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("model", pkg, snakeName+".go"),
|
||
Content: modelContent,
|
||
})
|
||
|
||
// ---- service ----
|
||
svcData := struct {
|
||
PackageName string
|
||
Feature codegenModel.Feature
|
||
}{pkg, feature}
|
||
svcContent, err := renderTmpl("service", serviceTmpl, funcMap, svcData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("service", pkg, snakeName+"_service.go"),
|
||
Content: svcContent,
|
||
})
|
||
|
||
// ---- api handler ----
|
||
apiData := struct {
|
||
PackageName string
|
||
Feature codegenModel.Feature
|
||
LowerName string
|
||
RouterPrefix string
|
||
PascalModuleName string
|
||
}{pkg, feature, lower, routerPrefix, pascalModule}
|
||
apiContent, err := renderTmpl("api", apiTmpl, funcMap, apiData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("api", "v1", pkg, snakeName+".go"),
|
||
Content: apiContent,
|
||
})
|
||
|
||
// ---- router ----
|
||
routerData := struct {
|
||
PackageName string
|
||
Feature codegenModel.Feature
|
||
LowerName string
|
||
RouterGroup string
|
||
}{pkg, feature, lower, routerGroup}
|
||
routerContent, err := renderTmpl("router", routerTmpl, funcMap, routerData)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, codegenModel.PreviewFile{
|
||
FilePath: filepath.Join("router", pkg, snakeName+"_router.go"),
|
||
Content: routerContent,
|
||
})
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func renderTmpl(name, tmplStr string, funcMap template.FuncMap, data any) (string, error) {
|
||
t, err := template.New(name).Funcs(funcMap).Parse(tmplStr)
|
||
if err != nil {
|
||
return "", fmt.Errorf("解析模板 [%s] 失败: %w", name, err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err = t.Execute(&buf, data); err != nil {
|
||
return "", fmt.Errorf("渲染模板 [%s] 失败: %w", name, err)
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// toSnake 将 PascalCase 转为 snake_case
|
||
func toSnake(s string) string {
|
||
var result []rune
|
||
for i, r := range s {
|
||
if r >= 'A' && r <= 'Z' {
|
||
if i > 0 {
|
||
result = append(result, '_')
|
||
}
|
||
result = append(result, r+32)
|
||
} else {
|
||
result = append(result, r)
|
||
}
|
||
}
|
||
return string(result)
|
||
}
|