373 lines
8.9 KiB
Go
373 lines
8.9 KiB
Go
package template
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"log/slog"
|
|
)
|
|
|
|
type TemplateLoader struct {
|
|
templates map[string]*template.Template
|
|
cache map[string]*template.Template
|
|
mu sync.RWMutex
|
|
config TemplateConfig
|
|
versionManager *VersionManager
|
|
hotReload *HotReloadWatcher
|
|
configLoader *ConfigLoader
|
|
logger *slog.Logger
|
|
}
|
|
|
|
type TemplateConfig struct {
|
|
BuiltinPath string
|
|
CustomPath string
|
|
CacheEnabled bool
|
|
HotReload bool
|
|
Templates map[string]struct {
|
|
Name string
|
|
Type string
|
|
Path string
|
|
Description string
|
|
Fields map[string]string
|
|
Config map[string]interface{}
|
|
}
|
|
TemplateTypes map[string]struct {
|
|
Layout string
|
|
Fields map[string]string
|
|
Options map[string]interface{}
|
|
}
|
|
}
|
|
|
|
|
|
func NewTemplateLoader(config TemplateConfig) *TemplateLoader {
|
|
loader := &TemplateLoader{
|
|
templates: make(map[string]*template.Template),
|
|
cache: make(map[string]*template.Template),
|
|
config: config,
|
|
logger: slog.With("component", "template_loader"),
|
|
versionManager: NewVersionManager(config.CustomPath),
|
|
configLoader: NewConfigLoader("./config"),
|
|
}
|
|
|
|
// Initialize hot reload if enabled
|
|
if config.HotReload {
|
|
var err error
|
|
loader.hotReload, err = NewHotReloadWatcher(loader, config.CustomPath)
|
|
if err != nil {
|
|
loader.logger.Error("failed to initialize hot reload", "error", err)
|
|
} else {
|
|
if err := loader.hotReload.Start(); err != nil {
|
|
loader.logger.Error("failed to start hot reload", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load configuration
|
|
if _, err := loader.configLoader.LoadTemplateConfig(); err != nil {
|
|
loader.logger.Warn("failed to load template config", "error", err)
|
|
}
|
|
|
|
return loader
|
|
}
|
|
|
|
func (tl *TemplateLoader) LoadTemplate(templateType, tableName string) (*template.Template, error) {
|
|
cacheKey := fmt.Sprintf("%s_%s", templateType, tableName)
|
|
|
|
tl.mu.RLock()
|
|
if cached, exists := tl.cache[cacheKey]; exists && tl.config.CacheEnabled {
|
|
tl.mu.RUnlock()
|
|
return cached, nil
|
|
}
|
|
tl.mu.RUnlock()
|
|
|
|
// 尝试加载自定义模板
|
|
customPath := filepath.Join(tl.config.CustomPath, tableName, fmt.Sprintf("%s.html", templateType))
|
|
if _, err := os.Stat(customPath); err == nil {
|
|
tmpl, err := tl.parseTemplate(customPath, templateType)
|
|
if err == nil {
|
|
tl.cacheTemplate(cacheKey, tmpl)
|
|
return tmpl, nil
|
|
}
|
|
}
|
|
|
|
// 回退到默认自定义模板
|
|
defaultPath := filepath.Join(tl.config.CustomPath, "_default", fmt.Sprintf("%s.html", templateType))
|
|
if _, err := os.Stat(defaultPath); err == nil {
|
|
tmpl, err := tl.parseTemplate(defaultPath, templateType)
|
|
if err == nil {
|
|
tl.cacheTemplate(cacheKey, tmpl)
|
|
return tmpl, nil
|
|
}
|
|
}
|
|
|
|
// 回退到内置模板
|
|
builtinPath := filepath.Join(tl.config.BuiltinPath, fmt.Sprintf("%s.html", templateType))
|
|
tmpl, err := tl.parseTemplate(builtinPath, templateType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load template %s: %w", templateType, err)
|
|
}
|
|
|
|
tl.cacheTemplate(cacheKey, tmpl)
|
|
return tmpl, nil
|
|
}
|
|
|
|
func (tl *TemplateLoader) parseTemplate(path, name string) (*template.Template, error) {
|
|
// 基础模板函数
|
|
funcs := template.FuncMap{
|
|
"dict": func(values ...interface{}) map[string]interface{} {
|
|
dict := make(map[string]interface{})
|
|
for i := 0; i < len(values); i += 2 {
|
|
if i+1 < len(values) {
|
|
key := fmt.Sprintf("%v", values[i])
|
|
dict[key] = values[i+1]
|
|
}
|
|
}
|
|
return dict
|
|
},
|
|
"add": func(a, b int) int { return a + b },
|
|
"sub": func(a, b int) int { return a - b },
|
|
"mul": func(a, b int) int { return a * b },
|
|
"min": func(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
"max": func(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
},
|
|
"json": func(v interface{}) string {
|
|
return fmt.Sprintf("%v", v)
|
|
},
|
|
"split": func(s string, sep string) []string {
|
|
return strings.Split(s, sep)
|
|
},
|
|
"until": func(n int) []int {
|
|
result := make([]int, n)
|
|
for i := 0; i < n; i++ {
|
|
result[i] = i + 1
|
|
}
|
|
return result
|
|
},
|
|
"eq": func(a, b interface{}) bool {
|
|
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
|
},
|
|
"ge": func(a, b int) bool {
|
|
return a >= b
|
|
},
|
|
"gt": func(a, b int) bool {
|
|
return a > b
|
|
},
|
|
"lt": func(a, b int) bool {
|
|
return a < b
|
|
},
|
|
"le": func(a, b int) bool {
|
|
return a <= b
|
|
},
|
|
"index": func(m interface{}, key string) interface{} {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
switch v := m.(type) {
|
|
case map[string]interface{}:
|
|
return v[key]
|
|
default:
|
|
return nil
|
|
}
|
|
},
|
|
"printf": func(format string, a ...interface{}) string {
|
|
return fmt.Sprintf(format, a...)
|
|
},
|
|
}
|
|
|
|
// 解析布局模板
|
|
layoutPath := filepath.Join(tl.config.BuiltinPath, "layout.html")
|
|
if _, err := os.Stat(layoutPath); err != nil {
|
|
return nil, fmt.Errorf("layout template not found: %w", err)
|
|
}
|
|
|
|
tmpl := template.New(name).Funcs(funcs)
|
|
|
|
// 加载布局模板
|
|
layoutContent, err := os.ReadFile(layoutPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read layout template: %w", err)
|
|
}
|
|
|
|
tmpl, err = tmpl.Parse(string(layoutContent))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse layout template: %w", err)
|
|
}
|
|
|
|
// 加载主模板
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read template %s: %w", path, err)
|
|
}
|
|
|
|
tmpl, err = tmpl.Parse(string(content))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse template %s: %w", path, err)
|
|
}
|
|
|
|
// 加载字段模板
|
|
fieldTemplates, err := tl.loadFieldTemplates()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load field templates: %w", err)
|
|
}
|
|
|
|
for _, fieldTpl := range fieldTemplates {
|
|
tmpl, err = tmpl.Parse(fieldTpl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse field template: %w", err)
|
|
}
|
|
}
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
func (tl *TemplateLoader) loadFieldTemplates() ([]string, error) {
|
|
var templates []string
|
|
|
|
// 加载内置字段模板
|
|
builtinFieldPath := filepath.Join(tl.config.BuiltinPath, "field")
|
|
if err := filepath.WalkDir(builtinFieldPath, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() && strings.HasSuffix(path, ".html") {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
templates = append(templates, string(content))
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return templates, nil
|
|
}
|
|
|
|
func (tl *TemplateLoader) cacheTemplate(key string, tmpl *template.Template) {
|
|
if !tl.config.CacheEnabled {
|
|
return
|
|
}
|
|
|
|
tl.mu.Lock()
|
|
tl.cache[key] = tmpl
|
|
tl.mu.Unlock()
|
|
}
|
|
|
|
func (tl *TemplateLoader) ClearCache() {
|
|
tl.mu.Lock()
|
|
defer tl.mu.Unlock()
|
|
|
|
for k := range tl.cache {
|
|
delete(tl.cache, k)
|
|
}
|
|
}
|
|
|
|
func (tl *TemplateLoader) watchTemplates() {
|
|
if tl.config.CustomPath == "" {
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
var lastModTime time.Time
|
|
|
|
for range ticker.C {
|
|
info, err := os.Stat(tl.config.CustomPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if info.ModTime().After(lastModTime) {
|
|
lastModTime = info.ModTime()
|
|
tl.ClearCache()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 验证模板完整性
|
|
func (tl *TemplateLoader) ValidateTemplate(templateType, tableName string) error {
|
|
_, err := tl.LoadTemplate(templateType, tableName)
|
|
return err
|
|
}
|
|
|
|
// 获取可用模板列表
|
|
func (tl *TemplateLoader) GetAvailableTemplates(tableName string) ([]string, error) {
|
|
var templates []string
|
|
|
|
// 检查自定义模板
|
|
customPath := filepath.Join(tl.config.CustomPath, tableName)
|
|
if entries, err := os.ReadDir(customPath); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".html") {
|
|
templates = append(templates, strings.TrimSuffix(entry.Name(), ".html"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 检查默认模板
|
|
defaultPath := filepath.Join(tl.config.CustomPath, "_default")
|
|
if entries, err := os.ReadDir(defaultPath); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".html") {
|
|
templateName := strings.TrimSuffix(entry.Name(), ".html")
|
|
if !contains(templates, templateName) {
|
|
templates = append(templates, templateName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 添加内置模板
|
|
builtinTemplates := []string{"list", "card", "timeline"}
|
|
for _, bt := range builtinTemplates {
|
|
if !contains(templates, bt) {
|
|
templates = append(templates, bt)
|
|
}
|
|
}
|
|
|
|
return templates, nil
|
|
}
|
|
|
|
func contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Fiber模板引擎适配器
|
|
func (tl *TemplateLoader) Render(data interface{}, templateName string, c fiber.Ctx) error {
|
|
parts := strings.Split(templateName, ":")
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("invalid template format, expected 'type:table'")
|
|
}
|
|
|
|
templateType, tableName := parts[0], parts[1]
|
|
tmpl, err := tl.LoadTemplate(templateType, tableName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.Set("Content-Type", "text/html; charset=utf-8")
|
|
return tmpl.Execute(c.Response().BodyWriter(), data)
|
|
} |