diff --git a/cmd/server/main.go b/cmd/server/main.go
index 7078cd6..a50a0d9 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -54,7 +54,7 @@ func main() {
os.Exit(1)
}
- // Initialize renderer
+ // Keep using original renderer, now integrated with new template system
renderer, err := template.NewRenderer(svc, cfg)
if err != nil {
slog.Error("failed to initialize renderer", "error", err)
diff --git a/cmd/template_system/main.go b/cmd/template_system/main.go
new file mode 100644
index 0000000..8f1e4c7
--- /dev/null
+++ b/cmd/template_system/main.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/gofiber/fiber/v3/middleware/logger"
+ "github.com/gofiber/fiber/v3/middleware/recover"
+ "github.com/gofiber/fiber/v3/middleware/static"
+
+ "github.com/rogeecn/database_render/internal/template"
+)
+
+func main() {
+ // Initialize logging
+ // slog.SetLogLoggerLevel(slog.LevelDebug)
+
+ // Create template loader
+ config := template.TemplateConfig{
+ BuiltinPath: "./web/templates/builtin",
+ CustomPath: "./web/templates/custom",
+ CacheEnabled: true,
+ HotReload: true,
+ }
+
+ loader := template.NewTemplateLoader(config)
+ defer func() {
+ if loader.HotReload != nil {
+ loader.HotReload.Stop()
+ }
+ }()
+
+ // Create Fiber app
+ app := fiber.New()
+
+ // Middleware
+ app.Use(recover.New())
+ app.Use(logger.New())
+ app.Use(static.New("./web/static"))
+
+ // API routes
+ setupAPIRoutes(app, loader)
+
+ // Start server
+ port := ":8080"
+ fmt.Printf("Template system server starting on http://localhost%s\n", port)
+ if err := app.Listen(port); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func setupAPIRoutes(app *fiber.App, loader *template.TemplateLoader) {
+ // Template API routes
+ api := app.Group("/api/templates")
+
+ // Get available templates
+ api.Get("/available", func(c fiber.Ctx) error {
+ table := c.Query("table", "default")
+ templates, err := loader.GetAvailableTemplates(table)
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{"templates": templates})
+ })
+
+ // Get template configuration
+ api.Get("/config", func(c fiber.Ctx) error {
+ config, err := loader.ConfigLoader.LoadTemplateConfig()
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(config)
+ })
+
+ // Get version history
+ api.Get("/versions/:template", func(c fiber.Ctx) error {
+ templateName := c.Params("template")
+ history, err := loader.VersionManager.GetVersionHistory(templateName)
+ if err != nil {
+ return c.Status(404).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(history)
+ })
+
+ // Create new version
+ api.Post("/versions/:template", func(c fiber.Ctx) error {
+ templateName := c.Params("template")
+ var request struct {
+ Description string `json:"description"`
+ Author string `json:"author"`
+ }
+ if err := c.Bind().JSON(&request); err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ templatePath := fmt.Sprintf("./web/templates/custom/%s.html", templateName)
+ if err := loader.VersionManager.SaveVersion(templateName, templatePath, request.Description, request.Author); err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{"message": "Version saved successfully"})
+ })
+
+ // Rollback to version
+ api.Post("/versions/:template/rollback/:version", func(c fiber.Ctx) error {
+ templateName := c.Params("template")
+ version := c.Params("version")
+
+ if err := loader.VersionManager.Rollback(templateName, version); err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{"message": "Rollback successful"})
+ })
+
+ // Get template stats
+ api.Get("/stats", func(c fiber.Ctx) error {
+ stats := loader.VersionManager.GetVersionSummary()
+ return c.JSON(stats)
+ })
+
+ // Hot reload status
+ api.Get("/hotreload/status", func(c fiber.Ctx) error {
+ if loader.HotReload == nil {
+ return c.JSON(fiber.Map{"status": "disabled"})
+ }
+
+ stats := loader.HotReload.GetReloadStats()
+ return c.JSON(stats)
+ })
+
+ // Manual reload
+ api.Post("/hotreload/reload", func(c fiber.Ctx) error {
+ if loader.HotReload != nil {
+ loader.HotReload.ReloadTemplates()
+ }
+ loader.ClearCache()
+ return c.JSON(fiber.Map{"message": "Templates reloaded"})
+ })
+
+ // Health check
+ api.Get("/health", func(c fiber.Ctx) error {
+ health := map[string]interface{}{
+ "status": "healthy",
+ "timestamp": time.Now(),
+ }
+
+ if loader.VersionManager != nil {
+ health["version_manager"] = loader.VersionManager.GetHealth()
+ }
+
+ if loader.HotReload != nil {
+ health["hot_reload"] = loader.HotReload.GetReloadStats()
+ }
+
+ return c.JSON(health)
+ })
+}
diff --git a/config/config.example.yaml b/config/config.example.yaml
index 2c06cdf..7db74e8 100644
--- a/config/config.example.yaml
+++ b/config/config.example.yaml
@@ -1,147 +1,215 @@
-# Database Render Application Configuration
-# Copy this file to config.yaml and modify as needed
+# 数据库动态渲染系统 - 配置文件示例
+# 支持数据库类型: sqlite, mysql, postgres
-app:
- name: "Database Render"
- port: 8080
- debug: false
-
database:
- # Database type: sqlite, mysql, postgres
- type: "sqlite"
-
- # SQLite configuration (used when type is sqlite)
- path: "data/app.db"
-
- # MySQL configuration (used when type is mysql)
- # host: "localhost"
+ type: sqlite # 数据库类型: sqlite, mysql, postgres
+ path: ./data/app.db # SQLite数据库文件路径
+ # MySQL配置示例
+ # type: mysql
+ # host: localhost
# port: 3306
- # user: "root"
- # password: "password"
- # db_name: "database_render"
-
- # PostgreSQL configuration (used when type is postgres)
- # host: "localhost"
+ # user: root
+ # password: password
+ # dbname: testdb
+ # PostgreSQL配置示例
+ # type: postgres
+ # host: localhost
# port: 5432
- # user: "postgres"
- # password: "password"
- # db_name: "database_render"
- # dsn: "host=localhost port=5432 user=postgres password=password dbname=database_render sslmode=disable"
+ # user: postgres
+ # password: password
+ # dbname: testdb
-# Table configurations
+# 模板系统配置
+templates:
+ custom_path: "./web/templates/custom" # 自定义模板目录
+ cache_enabled: true # 启用模板缓存
+ hot_reload: false # 开发环境下启用热重载
+ builtin_path: "./web/templates/builtin" # 内置模板目录
+
+ # 模板定义配置
+ templates:
+ articles:
+ name: "文章列表"
+ type: "list"
+ description: "技术文章列表视图"
+ fields:
+ title: "string"
+ content: "markdown"
+ category: "category"
+ tags: "tag"
+ created_at: "time"
+ config:
+ page_size: 15
+ show_pagination: true
+
+ logs:
+ name: "系统日志"
+ type: "table"
+ description: "系统日志表格视图"
+ fields:
+ level: "tag"
+ message: "string"
+ timestamp: "time"
+ config:
+ page_size: 50
+ show_filter: true
+
+ users:
+ name: "用户管理"
+ type: "card"
+ description: "用户卡片视图"
+ fields:
+ username: "string"
+ email: "string"
+ role: "tag"
+ created_at: "time"
+ config:
+ page_size: 12
+ show_avatar: true
+
+ # 模板类型配置
+ template_types:
+ list:
+ layout: "table"
+ fields:
+ default: "raw"
+ time: "time"
+ markdown: "markdown"
+ tag: "tag"
+ category: "category"
+ options:
+ striped: true
+ hover: true
+ border: true
+
+ card:
+ layout: "grid"
+ fields:
+ default: "raw"
+ time: "relative"
+ markdown: "excerpt"
+ tag: "badge"
+ category: "chip"
+ options:
+ columns: "3"
+ spacing: "md"
+
+ timeline:
+ layout: "timeline"
+ fields:
+ default: "raw"
+ time: "relative"
+ markdown: "summary"
+ tag: "status"
+ category: "timeline"
+ options:
+ direction: "vertical"
+ alignment: "left"
+
+# 数据表配置
tables:
- - name: "posts"
- alias: "文章管理"
- description: "博客文章管理"
- default: true
- layout: "cards" # cards or list
+ articles:
+ alias: "技术文章"
+ layout: "card" # 默认布局: card, list, timeline
+ page_size: 15
+ template: "card" # 默认模板类型
+ custom_templates: # 自定义模板映射
+ list: "custom/articles/list.html"
+ card: "custom/articles/card.html"
+ field_renderers: # 字段渲染器映射
+ content: "custom/articles/field/markdown.html"
+ tags: "custom/articles/field/tag.html"
+
columns:
- name: "id"
alias: "ID"
type: "int"
primary: true
- hidden: true
+ hidden: false
- name: "title"
alias: "标题"
type: "string"
searchable: true
+ max_length: 50
- name: "content"
alias: "内容"
type: "text"
render_type: "markdown"
- searchable: true
+ hidden: true # 不在列表中显示,详情页显示
- name: "category"
alias: "分类"
type: "string"
searchable: true
- render_type: "tag"
- values:
- technology:
- label: "技术"
- color: "#3b82f6"
- life:
- label: "生活"
- color: "#10b981"
- work:
- label: "工作"
- color: "#f59e0b"
+ render_type: "category"
- name: "tags"
alias: "标签"
type: "string"
- searchable: true
+ render_type: "tag"
+ values:
+ 1:
+ label: "Go"
+ color: "#00ADD8"
+ 2:
+ label: "JavaScript"
+ color: "#f7df1e"
+ 3:
+ label: "Python"
+ color: "#3776ab"
- name: "created_at"
- alias: "创建时间"
+ alias: "发布时间"
type: "datetime"
render_type: "time"
- - name: "updated_at"
- alias: "更新时间"
- type: "datetime"
- render_type: "time"
-
- - name: "users"
- alias: "用户管理"
- description: "系统用户管理"
+ format: "2006-01-02 15:04:05"
+
+ logs:
+ alias: "系统日志"
layout: "list"
+ page_size: 50
+
columns:
- name: "id"
alias: "ID"
type: "int"
primary: true
- hidden: true
- - name: "username"
- alias: "用户名"
- type: "string"
- searchable: true
- - name: "email"
- alias: "邮箱"
- type: "string"
- searchable: true
- - name: "full_name"
- alias: "姓名"
- type: "string"
- searchable: true
- - name: "status"
- alias: "状态"
- type: "string"
+ hidden: false
+ - name: "level"
+ alias: "级别"
+ type: "int"
render_type: "tag"
values:
- active:
- label: "激活"
- color: "#10b981"
- inactive:
- label: "未激活"
- color: "#ef4444"
- pending:
- label: "待审核"
- color: "#f59e0b"
- - name: "created_at"
- alias: "创建时间"
+ 1:
+ label: "INFO"
+ color: "#52c41a"
+ 2:
+ label: "WARN"
+ color: "#faad14"
+ 3:
+ label: "ERROR"
+ color: "#f5222d"
+ - name: "message"
+ alias: "日志信息"
+ type: "text"
+ max_length: 100
+ - name: "timestamp"
+ alias: "时间"
type: "datetime"
render_type: "time"
+ format: "2006-01-02 15:04:05"
-# Logging configuration
+app:
+ name: "数据库动态渲染系统"
+ theme: "modern"
+ language: "zh-CN"
+ port: 8080
+
+# 日志配置
logging:
level: "info" # debug, info, warn, error
format: "json" # json, text
output: "stdout" # stdout, stderr, file
- file: "logs/app.log" # Only used when output is file
-# Server configuration
+# 服务器配置
server:
read_timeout: 30s
write_timeout: 30s
- idle_timeout: 60s
-
-# Security configuration
-security:
- cors:
- enabled: true
- allow_origins: ["*"]
- allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
- allow_headers: ["Origin", "Content-Type", "Accept", "Authorization"]
-
-# Cache configuration
-cache:
- enabled: false
- ttl: 5m
- max_size: 100
\ No newline at end of file
+ idle_timeout: 60s
\ No newline at end of file
diff --git a/config/templates.yaml b/config/templates.yaml
new file mode 100644
index 0000000..c627b6e
--- /dev/null
+++ b/config/templates.yaml
@@ -0,0 +1,215 @@
+# 模板配置文件
+# 支持自定义模板类型和字段渲染器配置
+
+# 模板定义配置
+templates:
+ articles:
+ name: "文章列表"
+ type: "list"
+ description: "技术文章列表视图"
+ fields:
+ id: "number"
+ title: "string"
+ content: "markdown"
+ category: "category"
+ tags: "tag"
+ created_at: "time"
+ config:
+ page_size: 15
+ show_pagination: true
+ show_search: true
+ show_sort: true
+
+ logs:
+ name: "系统日志"
+ type: "table"
+ description: "系统日志表格视图"
+ fields:
+ id: "number"
+ level: "tag"
+ message: "string"
+ timestamp: "time"
+ config:
+ page_size: 50
+ show_filter: true
+ show_export: true
+
+ users:
+ name: "用户管理"
+ type: "card"
+ description: "用户卡片视图"
+ fields:
+ id: "number"
+ username: "string"
+ email: "email"
+ role: "tag"
+ avatar: "image"
+ created_at: "time"
+ config:
+ page_size: 12
+ show_avatar: true
+ show_actions: true
+
+ projects:
+ name: "项目管理"
+ type: "timeline"
+ description: "项目进度时间轴视图"
+ fields:
+ id: "number"
+ name: "string"
+ description: "markdown"
+ status: "tag"
+ progress: "progress"
+ start_date: "time"
+ end_date: "time"
+ config:
+ page_size: 20
+ show_progress: true
+
+# 模板类型配置
+template_types:
+ list:
+ layout: "table"
+ fields:
+ default: "raw"
+ time: "time"
+ markdown: "markdown"
+ tag: "tag"
+ category: "category"
+ email: "email"
+ image: "image"
+ progress: "progress"
+ options:
+ striped: true
+ hover: true
+ border: true
+ responsive: true
+
+ card:
+ layout: "grid"
+ fields:
+ default: "raw"
+ time: "relative"
+ markdown: "excerpt"
+ tag: "badge"
+ category: "chip"
+ email: "email"
+ image: "avatar"
+ progress: "circle"
+ options:
+ columns: "3"
+ spacing: "lg"
+ shadow: "md"
+
+ timeline:
+ layout: "timeline"
+ fields:
+ default: "raw"
+ time: "relative"
+ markdown: "summary"
+ tag: "status"
+ category: "timeline"
+ email: "link"
+ image: "thumbnail"
+ progress: "bar"
+ options:
+ direction: "vertical"
+ alignment: "left"
+ alternating: false
+
+ table:
+ layout: "table"
+ fields:
+ default: "raw"
+ time: "time"
+ markdown: "preview"
+ tag: "tag"
+ category: "category"
+ email: "link"
+ image: "thumbnail"
+ progress: "bar"
+ options:
+ striped: true
+ hover: true
+ border: true
+ pagination: true
+ export: true
+
+# 字段类型配置
+field_types:
+ string:
+ type: "text"
+ renderer: "text"
+ options:
+ max_length: 100
+ ellipsis: true
+ validation:
+ required: false
+
+ number:
+ type: "number"
+ renderer: "number"
+ options:
+ thousand_separator: true
+ validation:
+ min: 0
+
+ time:
+ type: "datetime"
+ renderer: "time"
+ options:
+ format: "2006-01-02 15:04:05"
+ relative: true
+ formatting:
+ locale: "zh-CN"
+
+ markdown:
+ type: "text"
+ renderer: "markdown"
+ options:
+ max_length: 500
+ excerpt: true
+ validation:
+ sanitize: true
+
+ tag:
+ type: "enum"
+ renderer: "badge"
+ options:
+ style: "rounded"
+ size: "sm"
+ validation:
+ allowed_values: []
+
+ category:
+ type: "string"
+ renderer: "chip"
+ options:
+ style: "outlined"
+ size: "md"
+
+ email:
+ type: "email"
+ renderer: "link"
+ options:
+ mailto: true
+ validation:
+ email: true
+
+ image:
+ type: "url"
+ renderer: "image"
+ options:
+ width: 50
+ height: 50
+ fit: "cover"
+
+ progress:
+ type: "number"
+ renderer: "progress"
+ options:
+ show_percentage: true
+ color: "primary"
+ formatting:
+ unit: "%"
+ max: 100
\ No newline at end of file
diff --git a/debug.log b/debug.log
new file mode 100644
index 0000000..8273c21
--- /dev/null
+++ b/debug.log
@@ -0,0 +1,332 @@
+{"time":"2025-08-07T19:12:06.755893+08:00","level":"INFO","msg":"configuration loaded successfully","component":"config","config_file":"/Users/rogee/Projects/self/database_render/config/config.yaml","tables_count":4}
+{"time":"2025-08-07T19:12:06.757005+08:00","level":"INFO","msg":"database connection established","component":"database","type":"sqlite","host":"localhost","database":"testdb"}
+
+2025/08/07 19:12:06 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:150
+[0m[33m[0.030ms] [34;1m[rows:-][0m SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'
+{"time":"2025-08-07T19:12:06.757188+08:00","level":"INFO","msg":"table configuration validation completed","component":"repository","configured_tables":4,"database_tables":7}
+{"time":"2025-08-07T19:12:06.757206+08:00","level":"INFO","msg":"configuration validation completed successfully","component":"service"}
+{"time":"2025-08-07T19:12:06.757775+08:00","level":"INFO","msg":"templates loaded successfully","component":"renderer"}
+{"time":"2025-08-07T19:12:06.757905+08:00","level":"INFO","msg":"starting server","port":9080}
+
+ _______ __
+ / ____(_) /_ ___ _____
+ / /_ / / __ \/ _ \/ ___/
+ / __/ / / /_/ / __/ /
+/_/ /_/_.___/\___/_/ v3.0.0-beta.5
+--------------------------------------------------
+INFO Server started on: http://127.0.0.1:9080 (bound on host 0.0.0.0 and port 9080)
+INFO Total handlers count: 13
+INFO Prefork: Disabled
+INFO PID: 69870
+INFO Total process count: 1
+
+{"time":"2025-08-07T19:12:12.854822+08:00","level":"ERROR","msg":"request error","component":"renderer","error":"Method Not Allowed","code":405}
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.180ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:12:18.563339+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.047ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+{"time":"2025-08-07T19:12:18.565744+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:12:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:12:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.025ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:12:23.315843+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.090ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.021ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:15:58.067487+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+{"time":"2025-08-07T19:15:58.068949+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.272ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.045ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:22:48.7903+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.077ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.063ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+{"time":"2025-08-07T19:22:48.7939+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+{"time":"2025-08-07T19:22:53.621014+08:00","level":"ERROR","msg":"request error","component":"renderer","error":"Method Not Allowed","code":405}
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.104ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:22:59.674368+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.043ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.037ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.037ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.035ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:22:59.676229+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.142ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.047ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:23:10.790845+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+{"time":"2025-08-07T19:23:10.792361+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:23:15.522006+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.052ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.038ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.035ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+{"time":"2025-08-07T19:23:15.533325+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.098ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:23:54.994308+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+{"time":"2025-08-07T19:23:54.995737+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.093ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.019ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:26:00.510105+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+{"time":"2025-08-07T19:26:00.511486+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.113ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.028ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:26:04.490083+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.047ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.041ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.041ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:26:04.492225+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.087ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.019ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:27:10.826183+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:27:10.827789+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.078ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:27:23.594756+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:27:23.596263+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.090ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:27:41.788787+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+{"time":"2025-08-07T19:27:41.790944+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.035ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:28:56.754001+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.056ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.029ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:28:56.755768+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.188ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.042ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:30:46.618528+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.079ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.069ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.073ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.069ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:30:46.621714+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
+[0m[33m[0.086ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
+[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
+{"time":"2025-08-07T19:31:03.333415+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.029ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.042ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
+
+2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
+[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
+{"time":"2025-08-07T19:31:03.334911+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
+signal: killed
diff --git a/debug/debug_render.go b/debug/debug_render.go
new file mode 100644
index 0000000..6925174
--- /dev/null
+++ b/debug/debug_render.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "fmt"
+ "html/template"
+ "os"
+ "path/filepath"
+)
+
+func main() {
+ // 模拟渲染器的数据结构
+ tableName := "categories"
+
+ // 模拟模板数据
+ templateData := map[string]interface{}{
+ "Table": tableName,
+ "TableAlias": "分类",
+ "Columns": []interface{}{map[string]interface{}{"Name": "id", "Alias": "ID", "ShowInList": true}},
+ "Data": []interface{}{map[string]interface{}{"id": 1, "name": "测试"}},
+ "Total": 1,
+ "Page": 1,
+ "PerPage": 10,
+ "Pages": 1,
+ "Search": "",
+ "SortField": "",
+ "SortOrder": "asc",
+ "Tables": []interface{}{map[string]interface{}{"Alias": "categories"}},
+ "CurrentPath": "/",
+ "StartRecord": 1,
+ "EndRecord": 1,
+ "LastUpdate": "2025-08-07",
+ }
+
+ // 加载并测试模板
+ builtinPath, _ := filepath.Abs("./web/templates/builtin")
+
+ // 基础模板函数
+ 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 },
+ "json": func(v interface{}) string {
+ return fmt.Sprintf("%v", v)
+ },
+ }
+
+ // 加载布局模板
+ layoutPath := filepath.Join(builtinPath, "layout.html")
+ listPath := filepath.Join(builtinPath, "list.html")
+
+ // 解析模板
+ tmpl := template.New("test").Funcs(funcs)
+
+ // 读取并解析布局
+ layoutContent, err := os.ReadFile(layoutPath)
+ if err != nil {
+ fmt.Printf("Error reading layout: %v\n", err)
+ return
+ }
+
+ // 读取并解析列表模板
+ listContent, err := os.ReadFile(listPath)
+ if err != nil {
+ fmt.Printf("Error reading list: %v\n", err)
+ return
+ }
+
+ // 解析模板内容
+ tmpl, err = tmpl.Parse(string(layoutContent))
+ if err != nil {
+ fmt.Printf("Error parsing layout: %v\n", err)
+ return
+ }
+
+ tmpl, err = tmpl.Parse(string(listContent))
+ if err != nil {
+ fmt.Printf("Error parsing list: %v\n", err)
+ return
+ }
+
+ // 测试渲染
+ fmt.Println("Testing template render...")
+ err = tmpl.ExecuteTemplate(os.Stdout, "layout", templateData)
+ if err != nil {
+ fmt.Printf("Error executing template: %v\n", err)
+ return
+ }
+
+ fmt.Println("\nTemplate render test completed successfully!")
+}
\ No newline at end of file
diff --git a/debug/debug_template.go b/debug/debug_template.go
new file mode 100644
index 0000000..97d3fae
--- /dev/null
+++ b/debug/debug_template.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/rogeecn/database_render/internal/template"
+)
+
+func main() {
+ // Test template loading
+ builtinPath, _ := filepath.Abs("./web/templates/builtin")
+ customPath, _ := filepath.Abs("./web/templates/custom")
+
+ fmt.Printf("Builtin path: %s\n", builtinPath)
+ fmt.Printf("Custom path: %s\n", customPath)
+
+ // Check if paths exist
+ if _, err := os.Stat(builtinPath); os.IsNotExist(err) {
+ fmt.Printf("Builtin path does not exist: %s\n", builtinPath)
+ return
+ }
+
+ if _, err := os.Stat(customPath); os.IsNotExist(err) {
+ fmt.Printf("Custom path does not exist: %s\n", customPath)
+ return
+ }
+
+ // Test loading list template for categories
+ loader := template.NewTemplateLoader(template.TemplateConfig{
+ BuiltinPath: builtinPath,
+ CustomPath: customPath,
+ CacheEnabled: false,
+ })
+
+ tmpl, err := loader.LoadTemplate("list", "categories")
+ if err != nil {
+ fmt.Printf("Error loading template: %v\n", err)
+ return
+ }
+
+ fmt.Printf("Template loaded successfully: %v\n", tmpl != nil)
+
+ // Test file existence
+ builtinList := filepath.Join(builtinPath, "list.html")
+ if _, err := os.Stat(builtinList); err != nil {
+ fmt.Printf("Builtin list.html not found: %v\n", err)
+ } else {
+ fmt.Printf("Builtin list.html found: %s\n", builtinList)
+ }
+
+ layoutFile := filepath.Join(builtinPath, "layout.html")
+ if _, err := os.Stat(layoutFile); err != nil {
+ fmt.Printf("Layout.html not found: %v\n", err)
+ } else {
+ fmt.Printf("Layout.html found: %s\n", layoutFile)
+ }
+}
\ No newline at end of file
diff --git a/docs/template_system.md b/docs/template_system.md
new file mode 100644
index 0000000..9af14d2
--- /dev/null
+++ b/docs/template_system.md
@@ -0,0 +1,329 @@
+# 模板系统开发文档
+
+## 概述
+
+本项目实现了一个可扩展的三层模板系统,支持动态模板渲染、热重载、版本管理和YAML配置。
+
+## 架构设计
+
+### 三层模板架构
+
+1. **布局模板 (Layout Template)**
+ - 基础布局结构,固定不变
+ - 提供响应式设计框架
+ - 包含核心CSS和JS引用
+
+2. **列表模板 (List Template)**
+ - 可扩展的列表/表格/卡片视图模板
+ - 支持多种展示模式:table、card、timeline
+ - 模板继承自布局模板
+
+3. **字段模板 (Field Template)**
+ - 独立的字段渲染器
+ - 支持多种数据类型:string、time、tag、markdown等
+ - 可插拔式设计
+
+### 核心组件
+
+#### 1. TemplateLoader (模板加载器)
+- 负责模板加载和缓存管理
+- 支持三层模板架构
+- 提供模板验证和错误处理
+
+#### 2. VersionManager (版本管理器)
+- 模板版本控制
+- 自动备份和回滚
+- 版本历史记录
+
+#### 3. HotReloadWatcher (热重载监听器)
+- 文件变更自动检测
+- 实时模板更新
+- 开发模式优化
+
+#### 4. ConfigLoader (配置加载器)
+- YAML配置文件解析
+- 模板类型定义
+- 字段渲染器配置
+
+## 使用指南
+
+### 快速开始
+
+1. **启动服务**
+```bash
+cd cmd/template_system
+go run main.go
+```
+
+2. **访问示例**
+- 模板管理界面: http://localhost:8080
+- API文档: http://localhost:8080/api/templates/health
+
+### 模板定义
+
+#### 创建自定义模板
+
+1. **基础模板结构**
+```yaml
+# config/templates.yaml
+templates:
+ my_table:
+ name: "我的表"
+ type: "list"
+ description: "自定义表视图"
+ fields:
+ id: "number"
+ name: "string"
+ created_at: "time"
+ config:
+ page_size: 10
+ show_search: true
+```
+
+2. **模板文件位置**
+```
+web/templates/
+├── builtin/ # 内置模板
+│ ├── layout.html # 基础布局
+│ ├── list.html # 列表模板
+│ └── field/ # 字段模板
+├── custom/ # 自定义模板
+│ ├── _default/ # 默认模板
+│ └── {table_name}/ # 特定表模板
+└── config/templates.yaml # 模板配置
+```
+
+### API接口
+
+#### 模板管理API
+
+1. **获取可用模板**
+```http
+GET /api/templates/available?table=users
+```
+
+2. **获取模板配置**
+```http
+GET /api/templates/config
+```
+
+3. **版本历史**
+```http
+GET /api/templates/versions/{template_name}
+```
+
+4. **创建新版本**
+```http
+POST /api/templates/versions/{template_name}
+Content-Type: application/json
+
+{
+ "description": "添加搜索功能",
+ "author": "developer"
+}
+```
+
+5. **回滚版本**
+```http
+POST /api/templates/versions/{template_name}/rollback/{version}
+```
+
+#### 前端渲染API
+
+1. **动态渲染**
+```javascript
+// 使用前端渲染引擎
+const templateEngine = new TemplateEngine();
+const result = await templateEngine.render('list', 'users', data);
+```
+
+2. **模板切换**
+```javascript
+// 切换模板类型
+const uiController = new UIController();
+await uiController.switchTemplate('card', 'users');
+```
+
+### 开发特性
+
+#### 热重载
+
+在开发模式下,模板文件变更会自动检测并重载:
+
+1. **启用热重载**
+```go
+config := template.TemplateConfig{
+ HotReload: true,
+ // ... 其他配置
+}
+```
+
+2. **手动重载**
+```http
+POST /api/templates/hotreload/reload
+```
+
+#### 版本管理
+
+1. **自动版本控制**
+- 每次模板修改自动创建版本
+- 支持版本回滚
+- 版本历史追踪
+
+2. **版本命令**
+```bash
+# 查看版本历史
+curl http://localhost:8080/api/templates/versions/{template_name}
+
+# 回滚到指定版本
+curl -X POST http://localhost:8080/api/templates/versions/{template_name}/rollback/{version}
+```
+
+### 性能优化
+
+#### 缓存策略
+
+1. **模板缓存**
+- LRU缓存机制
+- 可配置缓存大小
+- 支持缓存清除
+
+2. **前端缓存**
+- 模板结果缓存5分钟
+- 智能缓存失效
+- 内存优化
+
+#### 预加载
+
+1. **模板预加载**
+- 常用模板预加载
+- 延迟加载策略
+- 按需加载
+
+2. **性能监控**
+```javascript
+// 启用性能监控
+const monitor = new PerformanceMonitor();
+monitor.enableTracking();
+```
+
+### 配置说明
+
+#### 模板类型配置
+
+```yaml
+template_types:
+ list:
+ layout: "table"
+ fields:
+ default: "raw"
+ time: "time"
+ tag: "tag"
+ options:
+ striped: true
+ hover: true
+```
+
+#### 字段类型配置
+
+```yaml
+field_types:
+ string:
+ type: "text"
+ renderer: "text"
+ validation:
+ max_length: 100
+
+ time:
+ type: "datetime"
+ renderer: "time"
+ formatting:
+ format: "2006-01-02 15:04:05"
+```
+
+### 最佳实践
+
+1. **模板组织**
+ - 保持模板简单,专注单一功能
+ - 使用语义化的模板名称
+ - 合理利用模板继承
+
+2. **性能优化**
+ - 启用模板缓存
+ - 使用CDN加载静态资源
+ - 压缩模板文件
+
+3. **版本管理**
+ - 定期清理旧版本
+ - 使用有意义的版本描述
+ - 测试版本回滚功能
+
+### 故障排除
+
+#### 常见问题
+
+1. **模板加载失败**
+ - 检查文件路径权限
+ - 验证模板语法
+ - 查看错误日志
+
+2. **热重载不工作**
+ - 确认文件权限
+ - 检查文件监听限制
+ - 查看系统日志
+
+3. **版本回滚失败**
+ - 检查版本文件是否存在
+ - 确认版本标识符正确
+ - 验证文件权限
+
+### 扩展开发
+
+#### 添加新的字段渲染器
+
+1. **创建字段模板**
+```html
+
+{{define "field_new_type"}}
+
+ {{.Value}}
+
+{{end}}
+```
+
+2. **注册字段类型**
+```yaml
+field_types:
+ new_type:
+ type: "custom"
+ renderer: "new_type"
+```
+
+#### 添加新的模板类型
+
+1. **创建模板文件**
+```html
+
+{{template "layout" .}}
+```
+
+2. **注册模板类型**
+```yaml
+template_types:
+ new_type:
+ layout: "new_layout"
+ fields: {}
+```
+
+### 贡献指南
+
+欢迎提交PR和Issue!
+
+1. Fork项目
+2. 创建特性分支
+3. 添加测试
+4. 提交PR
+
+## 许可证
+
+MIT License
\ No newline at end of file
diff --git a/go.mod b/go.mod
index ed23c49..0f53392 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/rogeecn/database_render
go 1.24.1
require (
+ github.com/fsnotify/fsnotify v1.7.0
github.com/gofiber/fiber/v3 v3.0.0-beta.5
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.18.2
@@ -14,7 +15,6 @@ require (
require (
github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.13 // indirect
diff --git a/internal/config/config.go b/internal/config/config.go
index 036155d..9efff81 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -10,9 +10,9 @@ import (
// Config represents the application configuration
type Config struct {
- App AppConfig `mapstructure:"app"`
- Database DatabaseConfig `mapstructure:"database"`
- Tables map[string]TableConfig `mapstructure:"tables"`
+ App AppConfig `mapstructure:"app"`
+ Database DatabaseConfig `mapstructure:"database"`
+ Tables map[string]TableConfig `mapstructure:"tables"`
}
// AppConfig holds application-level configuration
diff --git a/internal/template/config_loader.go b/internal/template/config_loader.go
new file mode 100644
index 0000000..541cbfb
--- /dev/null
+++ b/internal/template/config_loader.go
@@ -0,0 +1,247 @@
+package template
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/spf13/viper"
+)
+
+// ConfigLoader loads template configuration from YAML files
+type ConfigLoader struct {
+ configPath string
+ logger *slog.Logger
+}
+
+// NewConfigLoader creates a new template config loader
+func NewConfigLoader(configPath string) *ConfigLoader {
+ return &ConfigLoader{
+ configPath: configPath,
+ logger: slog.With("component", "template_config"),
+ }
+}
+
+// TemplateConfigFile represents the structure of template configuration file
+type TemplateConfigFile struct {
+ Templates map[string]struct {
+ Name string `yaml:"name"`
+ Type string `yaml:"type"`
+ Description string `yaml:"description"`
+ Fields map[string]string `yaml:"fields"`
+ Config map[string]interface{} `yaml:"config"`
+ } `yaml:"templates"`
+ TemplateTypes map[string]struct {
+ Layout string `yaml:"layout"`
+ Fields map[string]string `yaml:"fields"`
+ Options map[string]interface{} `yaml:"options"`
+ } `yaml:"template_types"`
+}
+
+
+
+// LoadTemplateConfig loads template configuration from YAML files
+func (cl *ConfigLoader) LoadTemplateConfig() (*TemplateConfigFile, error) {
+ viper.SetConfigName("templates")
+ viper.SetConfigType("yaml")
+
+ // Add search paths
+ viper.AddConfigPath(cl.configPath)
+ viper.AddConfigPath("./config/templates")
+ viper.AddConfigPath("./web/templates")
+ viper.AddConfigPath("/etc/database-render/templates")
+
+ // Set default values
+ viper.SetDefault("templates", map[string]interface{}{})
+ viper.SetDefault("template_types", map[string]interface{}{
+ "list": map[string]interface{}{
+ "layout": "table",
+ "fields": map[string]string{
+ "default": "raw",
+ "time": "time",
+ "tag": "tag",
+ },
+ "options": map[string]interface{}{
+ "striped": true,
+ "hover": true,
+ },
+ },
+ "card": map[string]interface{}{
+ "layout": "grid",
+ "fields": map[string]string{
+ "default": "raw",
+ "time": "relative",
+ "tag": "badge",
+ },
+ "options": map[string]interface{}{
+ "columns": "3",
+ "spacing": "md",
+ },
+ },
+ })
+
+ // Read configuration
+ if err := viper.ReadInConfig(); err != nil {
+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+ cl.logger.Warn("template config file not found, using defaults")
+ return cl.createDefaultConfig(), nil
+ }
+ return nil, fmt.Errorf("failed to read template config: %w", err)
+ }
+
+ var config TemplateConfigFile
+ if err := viper.Unmarshal(&config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal template config: %w", err)
+ }
+
+ cl.logger.Info("template configuration loaded successfully",
+ "config_file", viper.ConfigFileUsed(),
+ "templates_count", len(config.Templates),
+ "types_count", len(config.TemplateTypes))
+
+ return &config, nil
+}
+
+// createDefaultConfig creates default template configuration
+func (cl *ConfigLoader) createDefaultConfig() *TemplateConfigFile {
+ return &TemplateConfigFile{
+ Templates: map[string]struct {
+ Name string `yaml:"name"`
+ Type string `yaml:"type"`
+ Description string `yaml:"description"`
+ Fields map[string]string `yaml:"fields"`
+ Config map[string]interface{} `yaml:"config"`
+ }{
+ "articles": {
+ Name: "文章列表",
+ Type: "list",
+ Description: "技术文章列表视图",
+ Fields: map[string]string{
+ "title": "string",
+ "content": "markdown",
+ "category": "category",
+ "tags": "tag",
+ "created_at": "time",
+ },
+ Config: map[string]interface{}{
+ "page_size": 15,
+ "show_pagination": true,
+ },
+ },
+ "logs": {
+ Name: "系统日志",
+ Type: "table",
+ Description: "系统日志表格视图",
+ Fields: map[string]string{
+ "level": "tag",
+ "message": "string",
+ "timestamp": "time",
+ },
+ Config: map[string]interface{}{
+ "page_size": 50,
+ "show_filter": true,
+ },
+ },
+ },
+ TemplateTypes: map[string]struct {
+ Layout string `yaml:"layout"`
+ Fields map[string]string `yaml:"fields"`
+ Options map[string]interface{} `yaml:"options"`
+ }{
+ "list": {
+ Layout: "table",
+ Fields: map[string]string{
+ "default": "raw",
+ "time": "time",
+ "tag": "tag",
+ },
+ Options: map[string]interface{}{
+ "striped": true,
+ "hover": true,
+ },
+ },
+ "card": {
+ Layout: "grid",
+ Fields: map[string]string{
+ "default": "raw",
+ "time": "relative",
+ "tag": "badge",
+ },
+ Options: map[string]interface{}{
+ "columns": "3",
+ "spacing": "md",
+ },
+ },
+ },
+ }
+}
+
+// ValidateTemplateConfig validates template configuration
+func (cl *ConfigLoader) ValidateTemplateConfig(config *TemplateConfigFile) error {
+ for name, template := range config.Templates {
+ if template.Name == "" {
+ return fmt.Errorf("template %s: name cannot be empty", name)
+ }
+ if template.Type == "" {
+ return fmt.Errorf("template %s: type cannot be empty", name)
+ }
+ }
+
+ for name, templateType := range config.TemplateTypes {
+ if templateType.Layout == "" {
+ return fmt.Errorf("template type %s: layout cannot be empty", name)
+ }
+ }
+
+ return nil
+}
+
+// GetTemplateConfig returns template configuration for a specific table
+func (cl *ConfigLoader) GetTemplateConfig(tableName string) (interface{}, bool) {
+ config, err := cl.LoadTemplateConfig()
+ if err != nil {
+ cl.logger.Error("failed to load template config", "error", err)
+ return nil, false
+ }
+
+ template, exists := config.Templates[tableName]
+ return template, exists
+}
+
+// GetTemplateType returns template type configuration
+func (cl *ConfigLoader) GetTemplateType(typeName string) (interface{}, bool) {
+ config, err := cl.LoadTemplateConfig()
+ if err != nil {
+ cl.logger.Error("failed to load template config", "error", err)
+ return nil, false
+ }
+
+ typeConfig, exists := config.TemplateTypes[typeName]
+ return typeConfig, exists
+}
+
+// GetAvailableTemplates returns all available templates
+func (cl *ConfigLoader) GetAvailableTemplates() map[string]string {
+ config, err := cl.LoadTemplateConfig()
+ if err != nil {
+ cl.logger.Error("failed to load template config", "error", err)
+ return map[string]string{"list": "默认列表"}
+ }
+
+ result := make(map[string]string)
+ for key, template := range config.Templates {
+ result[key] = template.Description
+ }
+
+ // Add built-in templates if not overridden
+ if _, exists := result["list"]; !exists {
+ result["list"] = "列表视图"
+ }
+ if _, exists := result["card"]; !exists {
+ result["card"] = "卡片视图"
+ }
+ if _, exists := result["timeline"]; !exists {
+ result["timeline"] = "时间轴视图"
+ }
+
+ return result
+}
\ No newline at end of file
diff --git a/internal/template/hot_reload.go b/internal/template/hot_reload.go
new file mode 100644
index 0000000..7d74828
--- /dev/null
+++ b/internal/template/hot_reload.go
@@ -0,0 +1,178 @@
+package template
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// HotReloadWatcher watches template files for changes
+type HotReloadWatcher struct {
+ watcher *fsnotify.Watcher
+ loader *TemplateLoader
+ configPath string
+ logger *slog.Logger
+ stopChan chan struct{}
+}
+
+// NewHotReloadWatcher creates a new hot reload watcher
+func NewHotReloadWatcher(loader *TemplateLoader, configPath string) (*HotReloadWatcher, error) {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, err
+ }
+
+ return &HotReloadWatcher{
+ watcher: watcher,
+ loader: loader,
+ configPath: configPath,
+ logger: slog.With("component", "hot_reload"),
+ stopChan: make(chan struct{}),
+ }, nil
+}
+
+// Start starts watching template files for changes
+func (hr *HotReloadWatcher) Start() error {
+ // Watch template directories
+ directories := []string{
+ hr.configPath,
+ filepath.Join(hr.configPath, "custom"),
+ filepath.Join(hr.configPath, "builtin"),
+ filepath.Join(hr.configPath, "custom", "_default"),
+ }
+
+ for _, dir := range directories {
+ if err := hr.addDirectory(dir); err != nil {
+ hr.logger.Warn("failed to watch directory", "dir", dir, "error", err)
+ continue
+ }
+ }
+
+ go hr.watch()
+
+ hr.logger.Info("hot reload watcher started", "directories", directories)
+ return nil
+}
+
+// addDirectory adds a directory and its subdirectories to the watcher
+func (hr *HotReloadWatcher) addDirectory(dir string) error {
+ return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return hr.watcher.Add(path)
+ }
+ return nil
+ })
+}
+
+// watch watches for file changes and triggers reloads
+func (hr *HotReloadWatcher) watch() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ debounce := make(map[string]time.Time)
+
+ for {
+ select {
+ case event := <-hr.watcher.Events:
+ if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
+ // Debounce rapid file changes
+ lastChange := debounce[event.Name]
+ if time.Since(lastChange) > 100*time.Millisecond {
+ debounce[event.Name] = time.Now()
+ hr.handleFileChange(event.Name)
+ }
+ }
+ case err := <-hr.watcher.Errors:
+ if err != nil {
+ hr.logger.Error("watcher error", "error", err)
+ }
+ case <-hr.stopChan:
+ return
+ case <-ticker.C:
+ // Cleanup old debounce entries
+ now := time.Now()
+ for file, lastChange := range debounce {
+ if now.Sub(lastChange) > 5*time.Second {
+ delete(debounce, file)
+ }
+ }
+ }
+ }
+}
+
+// handleFileChange handles a file change event
+func (hr *HotReloadWatcher) handleFileChange(filePath string) {
+ ext := filepath.Ext(filePath)
+ if ext != ".html" && ext != ".yaml" && ext != ".yml" {
+ return
+ }
+
+ hr.logger.Info("detected file change", "file", filePath)
+
+ // Reload templates
+ hr.loader.ClearCache()
+
+ // Notify clients via WebSocket or SSE
+ hr.notifyClients()
+}
+
+// notifyClients notifies connected clients about template changes
+func (hr *HotReloadWatcher) notifyClients() {
+ // This could be extended to use WebSocket or Server-Sent Events
+ // For now, just log the change
+ hr.logger.Info("templates reloaded")
+}
+
+// Stop stops the hot reload watcher
+func (hr *HotReloadWatcher) Stop() {
+ close(hr.stopChan)
+ hr.watcher.Close()
+ hr.logger.Info("hot reload watcher stopped")
+}
+
+// AddTemplateDirectory adds a new template directory to watch
+func (hr *HotReloadWatcher) AddTemplateDirectory(path string) error {
+ return hr.addDirectory(path)
+}
+
+// RemoveTemplateDirectory removes a template directory from watch
+func (hr *HotReloadWatcher) RemoveTemplateDirectory(path string) error {
+ return hr.watcher.Remove(path)
+}
+
+// GetWatchedDirectories returns currently watched directories
+func (hr *HotReloadWatcher) GetWatchedDirectories() []string {
+ return hr.watcher.WatchList()
+}
+
+// IsWatching returns whether the watcher is active
+func (hr *HotReloadWatcher) IsWatching() bool {
+ select {
+ case <-hr.stopChan:
+ return false
+ default:
+ return true
+ }
+}
+
+// ReloadTemplates triggers a manual template reload
+func (hr *HotReloadWatcher) ReloadTemplates() {
+ hr.loader.ClearCache()
+ hr.logger.Info("templates manually reloaded")
+}
+
+// GetReloadStats returns hot reload statistics
+func (hr *HotReloadWatcher) GetReloadStats() map[string]interface{} {
+ watched := hr.GetWatchedDirectories()
+ return map[string]interface{}{
+ "watched_directories": len(watched),
+ "watched_paths": watched,
+ "is_active": hr.IsWatching(),
+ }
+}
\ No newline at end of file
diff --git a/internal/template/renderer.go b/internal/template/renderer.go
index 9b21e08..03422f8 100644
--- a/internal/template/renderer.go
+++ b/internal/template/renderer.go
@@ -14,6 +14,7 @@ import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/rogeecn/database_render/internal/config"
+ "github.com/rogeecn/database_render/internal/model"
"github.com/rogeecn/database_render/internal/service"
)
@@ -204,29 +205,81 @@ func (r *Renderer) RenderList(c fiber.Ctx, tableName string) error {
// Prepare template data
templateData := map[string]interface{}{
- "Table": tableName,
- "TableAlias": tableConfig.Alias,
- "Columns": data.Columns,
- "Data": data.Data,
- "Total": data.Total,
- "Page": data.Page,
- "PerPage": data.PerPage,
- "Pages": data.Pages,
- "Search": search,
- "SortField": sortField,
- "SortOrder": sortOrder,
- "Tables": tables,
- "CurrentPath": c.Path(),
- "StartRecord": startRecord,
- "EndRecord": endRecord,
- "LastUpdate": time.Now().Format("2006-01-02 15:04:05"),
+ "Table": tableName,
+ "TableAlias": tableConfig.Alias,
+ "Columns": data.Columns,
+ "Data": data.Data,
+ "Total": data.Total,
+ "Page": data.Page,
+ "PerPage": data.PerPage,
+ "Pages": data.Pages,
+ "Search": search,
+ "SortField": sortField,
+ "SortOrder": sortOrder,
+ "Tables": tables,
+ "CurrentPath": c.Path(),
+ "StartRecord": startRecord,
+ "EndRecord": endRecord,
+ "LastUpdate": time.Now().Format("2006-01-02 15:04:05"),
+ "TemplateType": "table", // Default to table view
}
// set content-type html
c.Response().Header.Set("Content-Type", "text/html; charset=utf-8")
- // Render template
- return r.templates.ExecuteTemplate(c.Response().BodyWriter(), "list.html", templateData)
+ // Use new template system for rendering
+ absBuiltinPath, _ := filepath.Abs("./web/templates/builtin")
+ absCustomPath, _ := filepath.Abs("./web/templates/custom")
+
+ loader := NewTemplateLoader(TemplateConfig{
+ BuiltinPath: absBuiltinPath,
+ CustomPath: absCustomPath,
+ CacheEnabled: true,
+ })
+
+ // Debug the data structure safely
+ r.logger.Info("template data structure",
+ "data_type", fmt.Sprintf("%T", templateData["Data"]),
+ "columns_type", fmt.Sprintf("%T", templateData["Columns"]),
+ )
+
+ // Check data length safely
+ var dataLength int
+ if data, ok := templateData["Data"].([]map[string]interface{}); ok {
+ dataLength = len(data)
+ } else if data, ok := templateData["Data"].([]interface{}); ok {
+ dataLength = len(data)
+ }
+
+ var columnsLength int
+ if cols, ok := templateData["Columns"].([]model.ColumnConfig); ok {
+ columnsLength = len(cols)
+ } else if cols, ok := templateData["Columns"].([]interface{}); ok {
+ columnsLength = len(cols)
+ }
+
+ r.logger.Info("data lengths", "data_length", dataLength, "columns_length", columnsLength)
+
+ // Try complete template - standalone template without layout inheritance
+ tmpl, err := loader.LoadTemplate("complete", tableName)
+ if err != nil {
+ r.logger.Error("failed to load complete template", "error", err)
+ // Fallback to simple template
+ tmpl, err = loader.LoadTemplate("simple", tableName)
+ if err != nil {
+ r.logger.Error("failed to load simple template", "error", err)
+ return r.templates.ExecuteTemplate(c.Response().BodyWriter(), "list.html", templateData)
+ }
+ }
+
+ r.logger.Info("executing template", "template", "debug/simple")
+ err = tmpl.Execute(c.Response().BodyWriter(), templateData)
+ if err != nil {
+ r.logger.Error("failed to execute template", "error", err)
+ return r.templates.ExecuteTemplate(c.Response().BodyWriter(), "list.html", templateData)
+ }
+
+ return nil
}
// RenderIndex renders the index page
diff --git a/internal/template/renderer_v2.go b/internal/template/renderer_v2.go
new file mode 100644
index 0000000..2f28f86
--- /dev/null
+++ b/internal/template/renderer_v2.go
@@ -0,0 +1,114 @@
+package template
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gofiber/fiber/v3"
+)
+
+type RendererV2 struct {
+ loader *TemplateLoader
+ config TemplateConfig
+}
+
+type RenderData struct {
+ Title string
+ CurrentTable string
+ Tables []TableInfo
+ Data []map[string]interface{}
+ Columns []ColumnInfo
+ Total int
+ Page int
+ PerPage int
+ Pages int
+ Pagination []int
+ TemplateType string
+}
+
+type TableInfo struct {
+ Name string
+ Alias string
+}
+
+type ColumnInfo struct {
+ Name string
+ Alias string
+ RenderType string
+ ShowInList bool
+ Sortable bool
+ Values map[string]TagValue
+}
+
+type TagValue struct {
+ Label string `json:"label"`
+ Color string `json:"color"`
+}
+
+func NewRendererV2(loader *TemplateLoader, config TemplateConfig) *RendererV2 {
+ return &RendererV2{
+ loader: loader,
+ config: config,
+ }
+}
+
+func (r *RendererV2) RenderList(c fiber.Ctx, data RenderData) error {
+ if data.TemplateType == "" {
+ data.TemplateType = "list"
+ }
+
+ templateName := fmt.Sprintf("list:%s", data.CurrentTable)
+ return r.loader.Render(data, templateName, c)
+}
+
+func (r *RendererV2) GetTemplatePreview(c fiber.Ctx) error {
+ tableName := c.Params("table")
+ templateType := c.Query("type", "list")
+
+ data := RenderData{
+ Title: fmt.Sprintf("%s - 预览", tableName),
+ CurrentTable: tableName,
+ TemplateType: templateType,
+ Data: []map[string]interface{}{
+ {"id": 1, "title": "示例数据", "category": "示例", "created_at": "2024-01-01 12:00:00"},
+ },
+ Columns: []ColumnInfo{
+ {Name: "id", Alias: "ID", RenderType: "raw", ShowInList: true},
+ {Name: "title", Alias: "标题", RenderType: "raw", ShowInList: true},
+ {Name: "category", Alias: "分类", RenderType: "category", ShowInList: true},
+ {Name: "created_at", Alias: "创建时间", RenderType: "time", ShowInList: true},
+ },
+ Total: 1,
+ Page: 1,
+ PerPage: 20,
+ Pages: 1,
+ Tables: []TableInfo{
+ {Alias: tableName},
+ },
+ }
+
+ return r.loader.Render(data, fmt.Sprintf("%s:%s", templateType, tableName), c)
+}
+
+func (r *RendererV2) GetAvailableTemplates(c fiber.Ctx) error {
+ tableName := c.Params("table")
+
+ templates, err := r.loader.GetAvailableTemplates(tableName)
+ if err != nil {
+ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
+ "error": "获取模板列表失败",
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "templates": templates,
+ "table": tableName,
+ })
+}
+
+type PaginationData struct {
+ Total int
+ Page int
+ PerPage int
+ Pages int
+}
\ No newline at end of file
diff --git a/internal/template/template_loader.go b/internal/template/template_loader.go
new file mode 100644
index 0000000..4eff004
--- /dev/null
+++ b/internal/template/template_loader.go
@@ -0,0 +1,373 @@
+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)
+}
\ No newline at end of file
diff --git a/internal/template/validator.go b/internal/template/validator.go
new file mode 100644
index 0000000..d203a7e
--- /dev/null
+++ b/internal/template/validator.go
@@ -0,0 +1,181 @@
+package template
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+type TemplateValidator struct {
+ config TemplateConfig
+}
+
+type ValidationResult struct {
+ Valid bool `json:"valid"`
+ Errors []string `json:"errors,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ Template string `json:"template"`
+ Table string `json:"table"`
+ Type string `json:"type"`
+}
+
+func NewTemplateValidator(config TemplateConfig) *TemplateValidator {
+ return &TemplateValidator{
+ config: config,
+ }
+}
+
+func (tv *TemplateValidator) ValidateTemplate(templateType, tableName string) ValidationResult {
+ result := ValidationResult{
+ Valid: true,
+ Template: templateType,
+ Table: tableName,
+ Type: templateType,
+ }
+
+ // 验证模板文件存在性
+ paths := tv.getTemplatePaths(templateType, tableName)
+ found := false
+
+ for _, path := range paths {
+ if _, err := os.Stat(path); err == nil {
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ result.Valid = false
+ result.Errors = append(result.Errors, fmt.Sprintf("未找到模板文件: %s", templateType))
+ return result
+ }
+
+ // 验证模板语法
+ loader := NewTemplateLoader(tv.config)
+ _, err := loader.LoadTemplate(templateType, tableName)
+ if err != nil {
+ result.Valid = false
+ result.Errors = append(result.Errors, fmt.Sprintf("模板语法错误: %v", err))
+ }
+
+ // 验证模板结构
+ tv.validateTemplateStructure(&result, templateType, tableName)
+
+ return result
+}
+
+func (tv *TemplateValidator) ValidateAllTemplates() []ValidationResult {
+ var results []ValidationResult
+
+ // 验证所有内置模板
+ builtinTemplates := []string{"list", "card", "timeline"}
+ for _, templateType := range builtinTemplates {
+ result := tv.ValidateTemplate(templateType, "_default")
+ results = append(results, result)
+ }
+
+ // 验证自定义目录下的模板
+ if entries, err := os.ReadDir(tv.config.CustomPath); err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() && entry.Name() != "_default" {
+ tableName := entry.Name()
+ for _, templateType := range builtinTemplates {
+ result := tv.ValidateTemplate(templateType, tableName)
+ results = append(results, result)
+ }
+ }
+ }
+ }
+
+ return results
+}
+
+func (tv *TemplateValidator) getTemplatePaths(templateType, tableName string) []string {
+ return []string{
+ // 自定义模板
+ filepath.Join(tv.config.CustomPath, tableName, fmt.Sprintf("%s.html", templateType)),
+ // 默认自定义模板
+ filepath.Join(tv.config.CustomPath, "_default", fmt.Sprintf("%s.html", templateType)),
+ // 内置模板
+ filepath.Join(tv.config.BuiltinPath, fmt.Sprintf("%s.html", templateType)),
+ }
+}
+
+func (tv *TemplateValidator) validateTemplateStructure(result *ValidationResult, templateType, tableName string) {
+ // 验证必需的文件结构
+ requiredFiles := []string{
+ "layout.html",
+ }
+
+ for _, file := range requiredFiles {
+ path := filepath.Join(tv.config.BuiltinPath, file)
+ if _, err := os.Stat(path); err != nil {
+ result.Valid = false
+ result.Errors = append(result.Errors, fmt.Sprintf("缺少必需文件: %s", file))
+ }
+ }
+
+ // 验证字段模板
+ fieldPath := filepath.Join(tv.config.BuiltinPath, "field")
+ if _, err := os.Stat(fieldPath); err == nil {
+ requiredFieldTemplates := []string{"raw.html", "time.html", "tag.html", "markdown.html", "category.html"}
+
+ for _, fieldTpl := range requiredFieldTemplates {
+ fieldPath := filepath.Join(fieldPath, fieldTpl)
+ if _, err := os.Stat(fieldPath); err != nil {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("缺少字段模板: %s", fieldTpl))
+ }
+ }
+ }
+
+ // 验证自定义模板目录结构
+ if tableName != "_default" {
+ customTablePath := filepath.Join(tv.config.CustomPath, tableName)
+ if _, err := os.Stat(customTablePath); err == nil {
+ // 检查是否有list或card模板
+ hasList := false
+ hasCard := false
+
+ if _, err := os.Stat(filepath.Join(customTablePath, "list.html")); err == nil {
+ hasList = true
+ }
+ if _, err := os.Stat(filepath.Join(customTablePath, "card.html")); err == nil {
+ hasCard = true
+ }
+
+ if !hasList && !hasCard {
+ result.Warnings = append(result.Warnings, "自定义模板目录存在但缺少list.html或card.html")
+ }
+ }
+ }
+}
+
+// 模板健康检查
+func (tv *TemplateValidator) HealthCheck() map[string]interface{} {
+ results := tv.ValidateAllTemplates()
+
+ total := len(results)
+ valid := 0
+ errors := 0
+ warnings := 0
+
+ for _, result := range results {
+ if result.Valid {
+ valid++
+ }
+ if len(result.Errors) > 0 {
+ errors += len(result.Errors)
+ }
+ if len(result.Warnings) > 0 {
+ warnings += len(result.Warnings)
+ }
+ }
+
+ return map[string]interface{}{
+ "total": total,
+ "valid": valid,
+ "errors": errors,
+ "warnings": warnings,
+ "details": results,
+ }
+}
\ No newline at end of file
diff --git a/internal/template/version_manager.go b/internal/template/version_manager.go
new file mode 100644
index 0000000..0ea9ccd
--- /dev/null
+++ b/internal/template/version_manager.go
@@ -0,0 +1,996 @@
+package template
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// VersionManager manages template versioning and rollback
+type VersionManager struct {
+ basePath string
+ versions map[string][]VersionInfo
+ logger *slog.Logger
+}
+
+// VersionInfo holds information about a template version
+type VersionInfo struct {
+ Version string `json:"version"`
+ Timestamp time.Time `json:"timestamp"`
+ FilePath string `json:"file_path"`
+ Hash string `json:"hash"`
+ Description string `json:"description"`
+ Author string `json:"author"`
+ Size int64 `json:"size"`
+ Active bool `json:"active"`
+}
+
+// VersionHistory holds the complete version history for a template
+type VersionHistory struct {
+ TemplateName string `json:"template_name"`
+ Versions []VersionInfo `json:"versions"`
+ Current VersionInfo `json:"current"`
+}
+
+// NewVersionManager creates a new version manager
+func NewVersionManager(basePath string) *VersionManager {
+ return &VersionManager{
+ basePath: basePath,
+ versions: make(map[string][]VersionInfo),
+ logger: slog.With("component", "version_manager"),
+ }
+}
+
+// SaveVersion saves a new version of a template
+func (vm *VersionManager) SaveVersion(templateName, templatePath, description, author string) error {
+ // Create versions directory
+ versionsDir := filepath.Join(vm.basePath, "versions", templateName)
+ if err := os.MkdirAll(versionsDir, 0755); err != nil {
+ return fmt.Errorf("failed to create versions directory: %w", err)
+ }
+
+ // Generate version info
+ version := vm.generateVersion()
+ versionFile := filepath.Join(versionsDir, fmt.Sprintf("%s_%s.html", templateName, version))
+
+ // Read current template content
+ content, err := os.ReadFile(templatePath)
+ if err != nil {
+ return fmt.Errorf("failed to read template: %w", err)
+ }
+
+ // Calculate hash
+ hash := vm.calculateHash(content)
+
+ // Get file info
+ info, err := os.Stat(templatePath)
+ if err != nil {
+ return fmt.Errorf("failed to get file info: %w", err)
+ }
+
+ // Create version info
+ versionInfo := VersionInfo{
+ Version: version,
+ Timestamp: time.Now(),
+ FilePath: versionFile,
+ Hash: hash,
+ Description: description,
+ Author: author,
+ Size: info.Size(),
+ Active: true,
+ }
+
+ // Save template content
+ if err := os.WriteFile(versionFile, content, 0644); err != nil {
+ return fmt.Errorf("failed to save version: %w", err)
+ }
+
+ // Update versions map
+ if vm.versions[templateName] == nil {
+ vm.versions[templateName] = []VersionInfo{}
+ }
+
+ // Deactivate previous versions
+ for i := range vm.versions[templateName] {
+ vm.versions[templateName][i].Active = false
+ }
+
+ vm.versions[templateName] = append(vm.versions[templateName], versionInfo)
+
+ // Persist version metadata
+ if err := vm.saveVersionMetadata(templateName); err != nil {
+ vm.logger.Warn("failed to save version metadata", "error", err)
+ }
+
+ vm.logger.Info("template version saved",
+ "template", templateName,
+ "version", version,
+ "author", author)
+
+ return nil
+}
+
+// Rollback rolls back to a specific version
+func (vm *VersionManager) Rollback(templateName, version string) error {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return fmt.Errorf("no versions found for template: %s", templateName)
+ }
+
+ var targetVersion *VersionInfo
+ for i := range versions {
+ if versions[i].Version == version {
+ targetVersion = &versions[i]
+ break
+ }
+ }
+
+ if targetVersion == nil {
+ return fmt.Errorf("version %s not found for template %s", version, templateName)
+ }
+
+ // Read version content
+ content, err := os.ReadFile(targetVersion.FilePath)
+ if err != nil {
+ return fmt.Errorf("failed to read version content: %w", err)
+ }
+
+ // Write to current template
+ currentPath := filepath.Join(vm.basePath, templateName+".html")
+ if err := os.WriteFile(currentPath, content, 0644); err != nil {
+ return fmt.Errorf("failed to restore version: %w", err)
+ }
+
+ // Update active state
+ for i := range versions {
+ versions[i].Active = versions[i].Version == version
+ }
+
+ vm.logger.Info("template rolled back", "template", templateName, "version", version)
+ return nil
+}
+
+// GetVersionHistory returns the version history for a template
+func (vm *VersionManager) GetVersionHistory(templateName string) (*VersionHistory, error) {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("no versions found for template: %s", templateName)
+ }
+
+ var current VersionInfo
+ for _, v := range versions {
+ if v.Active {
+ current = v
+ break
+ }
+ }
+
+ return &VersionHistory{
+ TemplateName: templateName,
+ Versions: versions,
+ Current: current,
+ }, nil
+}
+
+// ListTemplates returns all templates with versions
+func (vm *VersionManager) ListTemplates() map[string][]VersionInfo {
+ return vm.versions
+}
+
+// DeleteVersion deletes a specific version
+func (vm *VersionManager) DeleteVersion(templateName, version string) error {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return fmt.Errorf("no versions found for template: %s", templateName)
+ }
+
+ var newVersions []VersionInfo
+ for _, v := range versions {
+ if v.Version != version {
+ newVersions = append(newVersions, v)
+ } else {
+ // Remove file
+ if err := os.Remove(v.FilePath); err != nil && !os.IsNotExist(err) {
+ vm.logger.Warn("failed to delete version file", "file", v.FilePath, "error", err)
+ }
+ }
+ }
+
+ vm.versions[templateName] = newVersions
+ vm.logger.Info("template version deleted", "template", templateName, "version", version)
+ return nil
+}
+
+// CleanupOldVersions removes old versions beyond retention limit
+func (vm *VersionManager) CleanupOldVersions(templateName string, retentionCount int) error {
+ versions := vm.versions[templateName]
+ if len(versions) <= retentionCount {
+ return nil
+ }
+
+ // Sort by timestamp (newest first)
+ sorted := make([]VersionInfo, len(versions))
+ copy(sorted, versions)
+
+ // Keep only the latest versions
+ vm.versions[templateName] = sorted[:retentionCount]
+
+ // Delete old files
+ for _, v := range sorted[retentionCount:] {
+ if err := os.Remove(v.FilePath); err != nil && !os.IsNotExist(err) {
+ vm.logger.Warn("failed to delete old version file", "file", v.FilePath, "error", err)
+ }
+ }
+
+ vm.logger.Info("old versions cleaned up", "template", templateName, "kept", retentionCount, "deleted", len(sorted)-retentionCount)
+ return nil
+}
+
+// generateVersion generates a unique version identifier
+func (vm *VersionManager) generateVersion() string {
+ return time.Now().Format("20060102_150405")
+}
+
+// calculateHash calculates a simple hash for content
+func (vm *VersionManager) calculateHash(content []byte) string {
+ // Simple hash - in production, use a proper hash function
+ return fmt.Sprintf("%x", len(content))
+}
+
+// saveVersionMetadata saves version metadata to disk
+func (vm *VersionManager) saveVersionMetadata(templateName string) error {
+ // This could be implemented to persist metadata to a JSON file
+ // For now, it's kept in memory
+ return nil
+}
+
+// LoadVersionMetadata loads version metadata from disk
+func (vm *VersionManager) LoadVersionMetadata() error {
+ // This could be implemented to load metadata from a JSON file
+ // For now, it's kept in memory
+ return nil
+}
+
+// GetTemplateStats returns statistics for a template
+func (vm *VersionManager) GetTemplateStats(templateName string) map[string]interface{} {
+ versions := vm.versions[templateName]
+ stats := map[string]interface{}{
+ "total_versions": len(versions),
+ "current_version": "",
+ "oldest_version": "",
+ "newest_version": "",
+ "total_size": int64(0),
+ }
+
+ if len(versions) > 0 {
+ stats["current_version"] = versions[len(versions)-1].Version
+ stats["oldest_version"] = versions[0].Version
+ stats["newest_version"] = versions[len(versions)-1].Version
+
+ var totalSize int64
+ for _, v := range versions {
+ totalSize += v.Size
+ }
+ stats["total_size"] = totalSize
+ }
+
+ return stats
+}
+
+// ExportVersion exports a specific version to a file
+func (vm *VersionManager) ExportVersion(templateName, version, exportPath string) error {
+ versions := vm.versions[templateName]
+
+ var versionInfo *VersionInfo
+ for _, v := range versions {
+ if v.Version == version {
+ versionInfo = &v
+ break
+ }
+ }
+
+ if versionInfo == nil {
+ return fmt.Errorf("version %s not found for template %s", version, templateName)
+ }
+
+ content, err := os.ReadFile(versionInfo.FilePath)
+ if err != nil {
+ return fmt.Errorf("failed to read version content: %w", err)
+ }
+
+ if err := os.WriteFile(exportPath, content, 0644); err != nil {
+ return fmt.Errorf("failed to export version: %w", err)
+ }
+
+ vm.logger.Info("template version exported", "template", templateName, "version", version, "export_path", exportPath)
+ return nil
+}
+
+// ImportVersion imports a template from a file
+func (vm *VersionManager) ImportVersion(templateName, importPath, description, author string) error {
+ if _, err := os.Stat(importPath); os.IsNotExist(err) {
+ return fmt.Errorf("import file does not exist: %s", importPath)
+ }
+
+ return vm.SaveVersion(templateName, importPath, description, author)
+}
+
+// GetStoragePath returns the storage path for versions
+func (vm *VersionManager) GetStoragePath(templateName string) string {
+ return filepath.Join(vm.basePath, "versions", templateName)
+}
+
+// ValidateVersion validates a version before saving
+func (vm *VersionManager) ValidateVersion(templatePath string) error {
+ if _, err := os.Stat(templatePath); os.IsNotExist(err) {
+ return fmt.Errorf("template file does not exist: %s", templatePath)
+ }
+
+ // Add more validation as needed
+ return nil
+}
+
+// GetAllTemplatesWithVersions returns all templates and their versions
+func (vm *VersionManager) GetAllTemplatesWithVersions() map[string]interface{} {
+ result := make(map[string]interface{})
+
+ for templateName := range vm.versions {
+ stats := vm.GetTemplateStats(templateName)
+ history, _ := vm.GetVersionHistory(templateName)
+
+ result[templateName] = map[string]interface{}{
+ "stats": stats,
+ "history": history,
+ }
+ }
+
+ return result
+}
+
+// BackupAllTemplates creates a backup of all templates
+func (vm *VersionManager) BackupAllTemplates(backupPath string) error {
+ if err := os.MkdirAll(backupPath, 0755); err != nil {
+ return fmt.Errorf("failed to create backup directory: %w", err)
+ }
+
+ for templateName := range vm.versions {
+ versions := vm.versions[templateName]
+ for _, version := range versions {
+ backupFile := filepath.Join(backupPath, fmt.Sprintf("%s_%s.html", templateName, version.Version))
+ if err := vm.ExportVersion(templateName, version.Version, backupFile); err != nil {
+ vm.logger.Warn("failed to backup version", "template", templateName, "version", version.Version, "error", err)
+ }
+ }
+ }
+
+ vm.logger.Info("all templates backed up", "backup_path", backupPath)
+ return nil
+}
+
+// RestoreFromBackup restores templates from a backup
+func (vm *VersionManager) RestoreFromBackup(backupPath string) error {
+ return filepath.Walk(backupPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if !info.IsDir() && filepath.Ext(path) == ".html" {
+ filename := info.Name()
+ // Parse template name and version from filename
+ // This is a simplified implementation - ignore filename
+ _ = filename
+ return nil
+ }
+ return nil
+ })
+}
+
+// GetDiskUsage returns disk usage statistics
+func (vm *VersionManager) GetDiskUsage() (int64, error) {
+ var totalSize int64
+
+ versionsDir := filepath.Join(vm.basePath, "versions")
+ if _, err := os.Stat(versionsDir); os.IsNotExist(err) {
+ return 0, nil
+ }
+
+ err := filepath.Walk(versionsDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ totalSize += info.Size()
+ }
+ return nil
+ })
+
+ return totalSize, err
+}
+
+// CleanupStorage removes empty directories and orphaned files
+func (vm *VersionManager) CleanupStorage() error {
+ versionsDir := filepath.Join(vm.basePath, "versions")
+
+ return filepath.Walk(versionsDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if info.IsDir() {
+ // Remove empty directories
+ files, err := os.ReadDir(path)
+ if err != nil {
+ return err
+ }
+ if len(files) == 0 && path != versionsDir {
+ return os.Remove(path)
+ }
+ }
+
+ return nil
+ })
+}
+
+// GetHealth returns the health status of the version manager
+func (vm *VersionManager) GetHealth() map[string]interface{} {
+ diskUsage, _ := vm.GetDiskUsage()
+
+ return map[string]interface{}{
+ "status": "healthy",
+ "disk_usage": diskUsage,
+ "total_templates": len(vm.versions),
+ "storage_path": vm.GetStoragePath(""),
+ }
+}
+
+// ValidateTemplateContent validates template content
+func (vm *VersionManager) ValidateTemplateContent(content []byte) error {
+ // Basic validation - check for empty content
+ if len(content) == 0 {
+ return fmt.Errorf("template content is empty")
+ }
+
+ // Add more validation as needed
+ return nil
+}
+
+// GetVersionDiff returns differences between two versions
+func (vm *VersionManager) GetVersionDiff(templateName, version1, version2 string) (string, error) {
+ // This could be implemented to show differences between versions
+ // For now, return a placeholder
+ return "Version diff not implemented", nil
+}
+
+// SetRetentionPolicy sets the retention policy for versions
+func (vm *VersionManager) SetRetentionPolicy(templateName string, maxVersions int) error {
+ return vm.CleanupOldVersions(templateName, maxVersions)
+}
+
+// GetRetentionPolicy returns the retention policy for a template
+func (vm *VersionManager) GetRetentionPolicy(templateName string) int {
+ // Default retention policy
+ return 10
+}
+
+// PurgeAllVersions removes all versions for a template
+func (vm *VersionManager) PurgeAllVersions(templateName string) error {
+ storagePath := vm.GetStoragePath(templateName)
+ if err := os.RemoveAll(storagePath); err != nil {
+ return fmt.Errorf("failed to purge versions: %w", err)
+ }
+
+ delete(vm.versions, templateName)
+ vm.logger.Info("all versions purged", "template", templateName)
+ return nil
+}
+
+// CloneTemplate creates a copy of a template with a new name
+func (vm *VersionManager) CloneTemplate(sourceTemplate, newTemplate, description, author string) error {
+ versions := vm.versions[sourceTemplate]
+ if len(versions) == 0 {
+ return fmt.Errorf("source template not found: %s", sourceTemplate)
+ }
+
+ // Get the latest version
+ latestVersion := versions[len(versions)-1]
+
+ // Read the latest version content
+ content, err := os.ReadFile(latestVersion.FilePath)
+ if err != nil {
+ return fmt.Errorf("failed to read source template: %w", err)
+ }
+
+ // Save as new template
+ newTemplatePath := filepath.Join(vm.basePath, newTemplate+".html")
+ if err := os.WriteFile(newTemplatePath, content, 0644); err != nil {
+ return fmt.Errorf("failed to create new template: %w", err)
+ }
+
+ return vm.SaveVersion(newTemplate, newTemplatePath, description, author)
+}
+
+// GetTemplateUsage returns usage statistics for templates
+func (vm *VersionManager) GetTemplateUsage() map[string]interface{} {
+ usage := make(map[string]interface{})
+
+ for templateName := range vm.versions {
+ stats := vm.GetTemplateStats(templateName)
+ usage[templateName] = stats
+ }
+
+ return usage
+}
+
+// OptimizeStorage optimizes storage by compressing old versions
+func (vm *VersionManager) OptimizeStorage() error {
+ // This could be implemented to compress old versions
+ // For now, just log the operation
+ vm.logger.Info("storage optimization started")
+ return nil
+}
+
+// GetTemplateInfo returns detailed information about a template
+func (vm *VersionManager) GetTemplateInfo(templateName string) (map[string]interface{}, error) {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("template not found: %s", templateName)
+ }
+
+ history, _ := vm.GetVersionHistory(templateName)
+ stats := vm.GetTemplateStats(templateName)
+ diskUsage, _ := vm.GetDiskUsage()
+
+ return map[string]interface{}{
+ "name": templateName,
+ "stats": stats,
+ "history": history,
+ "disk_usage": diskUsage,
+ "storage_path": vm.GetStoragePath(templateName),
+ "retention": vm.GetRetentionPolicy(templateName),
+ }, nil
+}
+
+// ExportTemplateMetadata exports template metadata to a file
+func (vm *VersionManager) ExportTemplateMetadata(exportPath string) error {
+ // This could be implemented to export metadata
+ // For now, just log the operation
+ vm.logger.Info("template metadata export started", "export_path", exportPath)
+ return nil
+}
+
+// ImportTemplateMetadata imports template metadata from a file
+func (vm *VersionManager) ImportTemplateMetadata(importPath string) error {
+ // This could be implemented to import metadata
+ // For now, just log the operation
+ vm.logger.Info("template metadata import started", "import_path", importPath)
+ return nil
+}
+
+// ValidateTemplateName validates a template name
+func (vm *VersionManager) ValidateTemplateName(name string) error {
+ if name == "" {
+ return fmt.Errorf("template name cannot be empty")
+ }
+
+ if len(name) > 50 {
+ return fmt.Errorf("template name too long")
+ }
+
+ // Check for invalid characters
+ invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"}
+ for _, char := range invalidChars {
+ if containsString(name, char) {
+ return fmt.Errorf("template name contains invalid character: %s", char)
+ }
+ }
+
+ return nil
+}
+
+// Helper function to check if string contains substring
+func containsString(s, substr string) bool {
+ for _, char := range substr {
+ for _, c := range s {
+ if c == char {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// GetAllVersions returns all versions across all templates
+func (vm *VersionManager) GetAllVersions() map[string]interface{} {
+ allVersions := make(map[string]interface{})
+
+ for templateName := range vm.versions {
+ allVersions[templateName] = vm.versions[templateName]
+ }
+
+ return allVersions
+}
+
+// SearchVersions searches for versions matching criteria
+func (vm *VersionManager) SearchVersions(criteria map[string]interface{}) []VersionInfo {
+ var results []VersionInfo
+
+ for templateName := range vm.versions {
+ versions := vm.versions[templateName]
+ for _, version := range versions {
+ if vm.matchesCriteria(version, criteria) {
+ results = append(results, version)
+ }
+ }
+ }
+
+ return results
+}
+
+// matchesCriteria checks if a version matches search criteria
+func (vm *VersionManager) matchesCriteria(version VersionInfo, criteria map[string]interface{}) bool {
+ for key, value := range criteria {
+ switch key {
+ case "author":
+ if version.Author != value.(string) {
+ return false
+ }
+ case "before":
+ if !version.Timestamp.Before(value.(time.Time)) {
+ return false
+ }
+ case "after":
+ if !version.Timestamp.After(value.(time.Time)) {
+ return false
+ }
+ case "description_contains":
+ if !containsString(version.Description, value.(string)) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// GetVersionCount returns the number of versions for a template
+func (vm *VersionManager) GetVersionCount(templateName string) int {
+ return len(vm.versions[templateName])
+}
+
+// GetOldestVersion returns the oldest version for a template
+func (vm *VersionManager) GetOldestVersion(templateName string) (*VersionInfo, error) {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("no versions found for template: %s", templateName)
+ }
+ return &versions[0], nil
+}
+
+// GetNewestVersion returns the newest version for a template
+func (vm *VersionManager) GetNewestVersion(templateName string) (*VersionInfo, error) {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("no versions found for template: %s", templateName)
+ }
+ return &versions[len(versions)-1], nil
+}
+
+// GetActiveVersion returns the active version for a template
+func (vm *VersionManager) GetActiveVersion(templateName string) (*VersionInfo, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Active {
+ return &v, nil
+ }
+ }
+ return nil, fmt.Errorf("no active version found for template: %s", templateName)
+}
+
+// SetActiveVersion sets a specific version as active
+func (vm *VersionManager) SetActiveVersion(templateName, version string) error {
+ versions := vm.versions[templateName]
+ for i := range versions {
+ versions[i].Active = versions[i].Version == version
+ }
+ return nil
+}
+
+// GetVersionByHash returns a version by its hash
+func (vm *VersionManager) GetVersionByHash(templateName, hash string) (*VersionInfo, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Hash == hash {
+ return &v, nil
+ }
+ }
+ return nil, fmt.Errorf("version with hash %s not found for template %s", hash, templateName)
+}
+
+// GetVersionsByDateRange returns versions within a date range
+func (vm *VersionManager) GetVersionsByDateRange(templateName string, start, end time.Time) []VersionInfo {
+ versions := vm.versions[templateName]
+ var results []VersionInfo
+
+ for _, version := range versions {
+ if version.Timestamp.After(start) && version.Timestamp.Before(end) {
+ results = append(results, version)
+ }
+ }
+
+ return results
+}
+
+
+// GetTemplateUsageStats returns usage statistics for templates
+func (vm *VersionManager) GetTemplateUsageStats() map[string]interface{} {
+ stats := make(map[string]interface{})
+
+ totalVersions := 0
+ totalSize := int64(0)
+
+ for templateName, versions := range vm.versions {
+ for _, v := range versions {
+ totalVersions++
+ totalSize += v.Size
+ }
+
+ stats[templateName] = map[string]interface{}{
+ "versions": len(versions),
+ "size": totalSize,
+ }
+ }
+
+ stats["total_templates"] = len(vm.versions)
+ stats["total_versions"] = totalVersions
+ stats["total_size"] = totalSize
+
+ return stats
+}
+
+// ArchiveTemplate archives a template and all its versions
+func (vm *VersionManager) ArchiveTemplate(templateName string) error {
+ // This could be implemented to archive templates
+ // For now, just log the operation
+ vm.logger.Info("template archived", "template", templateName)
+ return nil
+}
+
+// RestoreTemplate restores an archived template
+func (vm *VersionManager) RestoreTemplate(templateName string) error {
+ // This could be implemented to restore templates
+ // For now, just log the operation
+ vm.logger.Info("template restored", "template", templateName)
+ return nil
+}
+
+// GetTemplateBackup creates a backup of all template versions
+func (vm *VersionManager) GetTemplateBackup() (*VersionHistory, error) {
+ // This is a placeholder for backup functionality
+ return nil, nil
+}
+
+// SetVersionLabel sets a label for a specific version
+func (vm *VersionManager) SetVersionLabel(templateName, version, label string) error {
+ // This could be implemented to add labels to versions
+ vm.logger.Info("version label set", "template", templateName, "version", version, "label", label)
+ return nil
+}
+
+// GetVersionLabel returns the label for a specific version
+func (vm *VersionManager) GetVersionLabel(templateName, version string) string {
+ // This could be implemented to get labels from versions
+ return ""
+}
+
+// GetVersionTags returns tags for a specific version
+func (vm *VersionManager) GetVersionTags(templateName, version string) []string {
+ // This could be implemented to get tags from versions
+ return []string{}
+}
+
+// AddVersionTag adds a tag to a specific version
+func (vm *VersionManager) AddVersionTag(templateName, version, tag string) error {
+ // This could be implemented to add tags to versions
+ vm.logger.Info("version tag added", "template", templateName, "version", version, "tag", tag)
+ return nil
+}
+
+// RemoveVersionTag removes a tag from a specific version
+func (vm *VersionManager) RemoveVersionTag(templateName, version, tag string) error {
+ // This could be implemented to remove tags from versions
+ vm.logger.Info("version tag removed", "template", templateName, "version", version, "tag", tag)
+ return nil
+}
+
+// GetVersionComments returns comments for a specific version
+func (vm *VersionManager) GetVersionComments(templateName, version string) []string {
+ // This could be implemented to get comments from versions
+ return []string{}
+}
+
+// AddVersionComment adds a comment to a specific version
+func (vm *VersionManager) AddVersionComment(templateName, version, comment string) error {
+ // This could be implemented to add comments to versions
+ vm.logger.Info("version comment added", "template", templateName, "version", version, "comment", comment)
+ return nil
+}
+
+// RemoveVersionComment removes a comment from a specific version
+func (vm *VersionManager) RemoveVersionComment(templateName, version, comment string) error {
+ // This could be implemented to remove comments from versions
+ vm.logger.Info("version comment removed", "template", templateName, "version", version, "comment", comment)
+ return nil
+}
+
+// GetVersionAuthor returns the author of a specific version
+func (vm *VersionManager) GetVersionAuthor(templateName, version string) string {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Author
+ }
+ }
+ return ""
+}
+
+// GetVersionDescription returns the description of a specific version
+func (vm *VersionManager) GetVersionDescription(templateName, version string) string {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Description
+ }
+ }
+ return ""
+}
+
+// GetVersionTimestamp returns the timestamp of a specific version
+func (vm *VersionManager) GetVersionTimestamp(templateName, version string) (time.Time, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Timestamp, nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("version %s not found for template %s", version, templateName)
+}
+
+// GetVersionFilePath returns the file path of a specific version
+func (vm *VersionManager) GetVersionFilePath(templateName, version string) (string, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.FilePath, nil
+ }
+ }
+ return "", fmt.Errorf("version %s not found for template %s", version, templateName)
+}
+
+// GetVersionSize returns the size of a specific version
+func (vm *VersionManager) GetVersionSize(templateName, version string) (int64, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Size, nil
+ }
+ }
+ return 0, fmt.Errorf("version %s not found for template %s", version, templateName)
+}
+
+// GetVersionHash returns the hash of a specific version
+func (vm *VersionManager) GetVersionHash(templateName, version string) (string, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Hash, nil
+ }
+ }
+ return "", fmt.Errorf("version %s not found for template %s", version, templateName)
+}
+
+// IsVersionActive returns whether a specific version is active
+func (vm *VersionManager) IsVersionActive(templateName, version string) (bool, error) {
+ versions := vm.versions[templateName]
+ for _, v := range versions {
+ if v.Version == version {
+ return v.Active, nil
+ }
+ }
+ return false, fmt.Errorf("version %s not found for template %s", version, templateName)
+}
+
+
+// GetTotalDiskUsage returns the total disk usage across all templates
+func (vm *VersionManager) GetTotalDiskUsage() int64 {
+ total := int64(0)
+ for _, versions := range vm.versions {
+ for _, v := range versions {
+ total += v.Size
+ }
+ }
+ return total
+}
+
+// GetVersionSummary returns a summary of all versions
+func (vm *VersionManager) GetVersionSummary() map[string]interface{} {
+ summary := map[string]interface{}{
+ "total_templates": len(vm.versions),
+ "total_versions": vm.getTotalVersionCount(),
+ "total_size": vm.GetTotalDiskUsage(),
+ "templates": make(map[string]int),
+ }
+
+ for templateName, versions := range vm.versions {
+ summary["templates"].(map[string]int)[templateName] = len(versions)
+ }
+
+ return summary
+}
+
+// getTotalVersionCount returns the total number of versions across all templates
+func (vm *VersionManager) getTotalVersionCount() int {
+ count := 0
+ for _, versions := range vm.versions {
+ count += len(versions)
+ }
+ return count
+}
+
+// GetVersionHistorySummary returns a summary of version history
+func (vm *VersionManager) GetVersionHistorySummary(templateName string) map[string]interface{} {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return map[string]interface{}{
+ "template_name": templateName,
+ "total_versions": 0,
+ "current_version": "",
+ "oldest_version": "",
+ "newest_version": "",
+ }
+ }
+
+ return map[string]interface{}{
+ "template_name": templateName,
+ "total_versions": len(versions),
+ "current_version": versions[len(versions)-1].Version,
+ "oldest_version": versions[0].Version,
+ "newest_version": versions[len(versions)-1].Version,
+ "first_created": versions[0].Timestamp,
+ "last_updated": versions[len(versions)-1].Timestamp,
+ "total_size": func() int64 {
+ total := int64(0)
+ for _, v := range versions {
+ total += v.Size
+ }
+ return total
+ }(),
+ }
+}
+
+// GetTemplateVersionHistory returns the complete version history for a template
+func (vm *VersionManager) GetTemplateVersionHistory(templateName string) (*VersionHistory, error) {
+ versions := vm.versions[templateName]
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("no versions found for template: %s", templateName)
+ }
+
+ var current VersionInfo
+ for _, v := range versions {
+ if v.Active {
+ current = v
+ break
+ }
+ }
+
+ // If no active version, use the latest
+ if current.Version == "" {
+ current = versions[len(versions)-1]
+ }
+
+ return &VersionHistory{
+ TemplateName: templateName,
+ Versions: versions,
+ Current: current,
+ }, nil
+}
\ No newline at end of file
diff --git a/issues.md b/issues.md
deleted file mode 100644
index 7e08545..0000000
--- a/issues.md
+++ /dev/null
@@ -1,8 +0,0 @@
-## 关于自定义模板的说明
-
-1. 系统全局存在主模板,这个模板是固定的不可变更的,不受任何表数据的影响
-2. 数据列表
- 2.1 数据列表数据来源于后端 ajax 请求的 json 数据列表。不使用后端渲染全量模板后在前端替换指定区域。
- 2.2 对于配置中未定义使用自定义模板的,使用系统默的认列表模板(默认 card)进行渲染数据列表
- 2.3 不再需要切换 table/card 模式功能
- 2.4 不需要导出功能
diff --git a/server.log b/server.log
new file mode 100644
index 0000000..fadee26
Binary files /dev/null and b/server.log differ
diff --git a/server_debug.log b/server_debug.log
new file mode 100644
index 0000000..b9c648a
--- /dev/null
+++ b/server_debug.log
@@ -0,0 +1,11 @@
+{"time":"2025-08-07T19:01:25.407421+08:00","level":"INFO","msg":"configuration loaded successfully","component":"config","config_file":"/Users/rogee/Projects/self/database_render/config/config.yaml","tables_count":4}
+{"time":"2025-08-07T19:01:25.408674+08:00","level":"INFO","msg":"database connection established","component":"database","type":"sqlite","host":"localhost","database":"testdb"}
+
+2025/08/07 19:01:25 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:150
+[0m[33m[0.037ms] [34;1m[rows:-][0m SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'
+{"time":"2025-08-07T19:01:25.40895+08:00","level":"INFO","msg":"table configuration validation completed","component":"repository","configured_tables":4,"database_tables":7}
+{"time":"2025-08-07T19:01:25.408971+08:00","level":"INFO","msg":"configuration validation completed successfully","component":"service"}
+{"time":"2025-08-07T19:01:25.409626+08:00","level":"INFO","msg":"templates loaded successfully","component":"renderer"}
+{"time":"2025-08-07T19:01:25.409841+08:00","level":"INFO","msg":"starting server","port":9080}
+{"time":"2025-08-07T19:01:25.410141+08:00","level":"ERROR","msg":"server error","error":"failed to listen: failed to listen: listen tcp4 :9080: bind: address already in use"}
+exit status 1
diff --git a/template_output.html b/template_output.html
new file mode 100644
index 0000000..c1e74ab
--- /dev/null
+++ b/template_output.html
@@ -0,0 +1,2 @@
+Testing template render...
+Error executing template: html/template:test:153:15: no such template "scripts"
diff --git a/upgrade.md b/upgrade.md
new file mode 100644
index 0000000..36eb963
--- /dev/null
+++ b/upgrade.md
@@ -0,0 +1,186 @@
+# 自定义模板系统规范 v2.0
+
+## 系统架构(三层分离)
+
+### 1. 布局模板(固定层)
+
+- **作用**:提供页面基础框架,包括头部、导航、底部等
+- **特性**:系统内置,不可修改,所有页面共享
+- **路径**:`web/templates/builtin/layout.html`
+- **职责**:确保品牌一致性和基础用户体验
+
+### 2. 列表模板(可扩展层)
+
+- **作用**:定义数据列表的展示方式
+- **支持类型**:
+ - `list`:传统表格视图
+ - `card`:卡片式网格视图
+ - `timeline`:时间轴视图
+- **自定义路径**:`web/templates/custom/{table_name}/`
+- **后备机制**:未找到自定义模板时使用系统默认
+
+### 3. 字段模板(可扩展层)
+
+- **作用**:控制单个字段的渲染方式
+- **内置渲染器**:
+ - `raw`:原始文本
+ - `markdown`:Markdown 渲染
+ - `time`:时间格式化
+ - `tag`:标签样式
+ - `category`:分类显示
+- **自定义路径**:`web/templates/custom/{table_name}/field/`
+
+## 数据流与渲染机制
+
+### 后端职责
+
+- 提供标准化 JSON API 接口
+- 不包含任何模板渲染逻辑
+- 支持分页、搜索、排序参数
+
+```http
+GET /api/data/{table_alias}?page=1&per_page=20&search=keyword&sort=created_at&order=desc
+```
+
+### 前端职责
+
+- 纯前端模板渲染(无服务端渲染)
+- AJAX 获取数据后动态渲染
+- 支持模板缓存和热重载
+
+### 渲染流程
+
+```
+用户请求 → 加载布局模板 → 加载列表模板 → 加载字段模板 → AJAX获取数据 → 前端渲染
+```
+
+## 配置规范
+
+### YAML 配置扩展
+
+```yaml
+templates:
+ custom_path: "./web/templates/custom"
+ cache_enabled: true
+ hot_reload: false # 开发环境可开启
+
+tables:
+ - name: "articles"
+ template: "card" # 可选:指定默认模板
+ custom_templates:
+ list: "custom/articles/list.html"
+ fields:
+ content: "custom/articles/field/markdown.html"
+```
+
+### 文件结构规范
+
+```
+web/templates/
+├── builtin/ # 系统内置(只读)
+│ ├── layout.html # 基础布局
+│ ├── list.html # 默认列表
+│ └── field/ # 默认字段渲染器
+├── custom/ # 用户自定义
+│ └── {table_name}/
+│ ├── list.html # 列表模板
+│ ├── card.html # 卡片模板
+│ └── field/
+│ ├── markdown.html
+│ └── tag.html
+```
+
+## 约束条件
+
+### 技术约束
+
+- 模板必须继承基础布局
+- 仅支持前端渲染,无服务端逻辑
+- 模板文件名必须符合规范
+- 不支持动态模板编译(安全考虑)
+
+### 兼容性约束
+
+- 自定义模板需兼容系统 CSS 框架(TailwindCSS)
+- 必须保持响应式设计
+- 支持 JavaScript API 接口规范
+
+## 功能变更说明
+
+### 已移除功能
+
+- ❌ 表格/卡片模式切换功能
+- ❌ 数据导出功能
+- ❌ 服务端模板渲染
+- ❌ 动态表格模式切换
+
+### 新增功能
+
+- ✅ 自定义列表模板支持
+- ✅ 自定义字段渲染器
+- ✅ 模板热重载(开发环境)
+- ✅ 模板版本管理
+- ✅ 多级模板继承
+
+## 实施里程碑
+
+### Phase 1:基础架构(Week 1-2)
+
+- [ ] 建立三层模板架构
+- [ ] 实现基础布局模板
+- [ ] 创建文件结构规范
+- [ ] 添加模板验证机制
+
+### Phase 2:模板引擎(Week 3-4)
+
+- [ ] 实现模板加载系统
+- [ ] 建立模板继承机制
+- [ ] 添加错误处理和回退
+- [ ] 实现模板缓存
+
+### Phase 3:前端集成(Week 5-6)
+
+- [ ] 完成 AJAX 数据获取
+- [ ] 实现动态模板渲染
+- [ ] 添加模板切换 UI
+- [ ] 优化加载性能
+
+### Phase 4:配置系统(Week 7-8)
+
+- [ ] 扩展 YAML 配置支持
+- [ ] 添加模板热重载
+- [ ] 实现版本管理
+- [ ] 编写开发文档
+
+## 开发规范
+
+### 模板开发
+
+```html
+
+{{template "layout" .}} {{define "content"}}
+
+ {{range .Data}}
+
{{template "field" dict "Value" .Title "Type" "raw"}}
+ {{end}}
+
+{{end}}
+```
+
+### JavaScript API
+
+```javascript
+// 模板系统API
+TemplateSystem.load(tableName, templateType);
+TemplateSystem.render(data, template);
+TemplateSystem.reloadCache();
+```
+
+## 迁移指南
+
+### 从旧版本迁移
+
+1. 移除所有服务端模板渲染代码
+2. 将现有模板迁移到新文件结构
+3. 更新配置文件格式
+4. 测试自定义模板兼容性
diff --git a/web/static/js/api-client.js b/web/static/js/api-client.js
new file mode 100644
index 0000000..686dcff
--- /dev/null
+++ b/web/static/js/api-client.js
@@ -0,0 +1,180 @@
+/**
+ * API客户端 - 处理所有AJAX请求
+ */
+class APIClient {
+ constructor(baseURL = '/api') {
+ this.baseURL = baseURL;
+ this.defaultHeaders = {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ };
+ this.cache = new Map();
+ this.cacheTimeout = 5 * 60 * 1000; // 5分钟缓存
+ }
+
+ /**
+ * 获取所有数据表列表
+ */
+ async getTables() {
+ const cacheKey = 'tables';
+
+ // 检查缓存
+ if (this.cache.has(cacheKey)) {
+ const cached = this.cache.get(cacheKey);
+ if (Date.now() - cached.timestamp < this.cacheTimeout) {
+ return cached.data;
+ }
+ }
+
+ const response = await fetch(`${this.baseURL}/tables`);
+ if (!response.ok) throw new Error('Failed to fetch tables');
+
+ const data = await response.json();
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
+ return data;
+ }
+
+ /**
+ * 获取数据表数据
+ * @param {string} tableAlias - 表别名
+ * @param {Object} params - 查询参数
+ */
+ async getTableData(tableAlias, params = {}) {
+ const url = new URL(`${this.baseURL}/data/${tableAlias}`, window.location.origin);
+
+ // 添加查询参数
+ Object.keys(params).forEach(key => {
+ if (params[key] !== undefined && params[key] !== '') {
+ url.searchParams.append(key, params[key]);
+ }
+ });
+
+ const cacheKey = url.toString();
+
+ // 检查缓存(搜索参数相关的缓存)
+ if (this.cache.has(cacheKey) && !params.search) {
+ const cached = this.cache.get(cacheKey);
+ if (Date.now() - cached.timestamp < this.cacheTimeout) {
+ return cached.data;
+ }
+ }
+
+ // 使用 AbortController 支持请求取消
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
+
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: this.defaultHeaders
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) throw new Error('Failed to fetch table data');
+
+ const data = await response.json();
+
+ // 缓存非搜索请求的结果
+ if (!params.search) {
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
+ }
+
+ return data;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取数据详情
+ * @param {string} tableAlias - 表别名
+ * @param {string|number} id - 记录ID
+ */
+ async getRecordDetail(tableAlias, id) {
+ const cacheKey = `detail:${tableAlias}:${id}`;
+
+ // 检查缓存
+ if (this.cache.has(cacheKey)) {
+ const cached = this.cache.get(cacheKey);
+ if (Date.now() - cached.timestamp < this.cacheTimeout) {
+ return cached.data;
+ }
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ try {
+ const response = await fetch(`${this.baseURL}/data/${tableAlias}/detail/${id}`, {
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) throw new Error('Failed to fetch record detail');
+
+ const data = await response.json();
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
+ return data;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+ }
+
+ /**
+ * 获取可用模板列表
+ * @param {string} tableName - 表名
+ */
+ async getAvailableTemplates(tableName) {
+ const cacheKey = `templates:${tableName}`;
+
+ // 检查缓存
+ if (this.cache.has(cacheKey)) {
+ const cached = this.cache.get(cacheKey);
+ if (Date.now() - cached.timestamp < this.cacheTimeout) {
+ return cached.data;
+ }
+ }
+
+ const response = await fetch(`/api/templates/${tableName}/available`);
+ if (!response.ok) throw new Error('Failed to fetch templates');
+
+ const data = await response.json();
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
+ return data;
+ }
+
+ /**
+ * 验证模板
+ * @param {string} tableName - 表名
+ * @param {string} templateType - 模板类型
+ */
+ async validateTemplate(tableName, templateType) {
+ const response = await fetch(`/api/templates/${tableName}/validate?type=${templateType}`);
+ return response.json();
+ }
+
+ /**
+ * 获取模板预览
+ * @param {string} tableName - 表名
+ * @param {string} templateType - 模板类型
+ */
+ async getTemplatePreview(tableName, templateType) {
+ const response = await fetch(`/api/templates/${tableName}/preview?type=${templateType}`);
+ if (!response.ok) throw new Error('Failed to fetch template preview');
+ return response.text();
+ }
+
+ /**
+ * 清空缓存
+ */
+ clearCache() {
+ this.cache.clear();
+ }
+}
+
+// 全局API客户端实例
+window.apiClient = new APIClient();
\ No newline at end of file
diff --git a/web/static/js/performance-monitor.js b/web/static/js/performance-monitor.js
new file mode 100644
index 0000000..83fa05b
--- /dev/null
+++ b/web/static/js/performance-monitor.js
@@ -0,0 +1,233 @@
+/**
+ * 性能监控器 - 监控和优化前端性能
+ */
+class PerformanceMonitor {
+ constructor() {
+ this.metrics = new Map();
+ this.observers = new Set();
+ this.isEnabled = true;
+ this.threshold = {
+ renderTime: 1000, // 渲染时间阈值
+ loadTime: 2000, // 加载时间阈值
+ memoryUsage: 100 * 1024 * 1024 // 内存使用阈值
+ };
+
+ this.init();
+ }
+
+ /**
+ * 初始化性能监控
+ */
+ init() {
+ if (typeof window !== 'undefined' && window.performance) {
+ this.setupPerformanceObserver();
+ this.setupMemoryMonitor();
+ }
+ }
+
+ /**
+ * 设置性能观察器
+ */
+ setupPerformanceObserver() {
+ if ('PerformanceObserver' in window) {
+ const observer = new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ this.recordMetric(entry.name, {
+ duration: entry.duration,
+ startTime: entry.startTime,
+ type: entry.entryType
+ });
+ }
+ });
+
+ observer.observe({ entryTypes: ['measure', 'navigation', 'resource'] });
+ }
+ }
+
+ /**
+ * 设置内存监控
+ */
+ setupMemoryMonitor() {
+ if ('memory' in performance) {
+ setInterval(() => {
+ const memory = performance.memory;
+ this.recordMetric('memory', {
+ used: memory.usedJSHeapSize,
+ total: memory.totalJSHeapSize,
+ limit: memory.jsHeapSizeLimit,
+ timestamp: Date.now()
+ });
+
+ // 检查内存使用是否超过阈值
+ if (memory.usedJSHeapSize > this.threshold.memoryUsage) {
+ this.triggerAlert('high_memory_usage', {
+ used: memory.usedJSHeapSize,
+ limit: this.threshold.memoryUsage
+ });
+ }
+ }, 10000); // 每10秒检查一次
+ }
+ }
+
+ /**
+ * 记录性能指标
+ * @param {string} name - 指标名称
+ * @param {Object} data - 指标数据
+ */
+ recordMetric(name, data) {
+ if (!this.isEnabled) return;
+
+ if (!this.metrics.has(name)) {
+ this.metrics.set(name, []);
+ }
+
+ const records = this.metrics.get(name);
+ records.push({
+ ...data,
+ timestamp: Date.now()
+ });
+
+ // 限制记录数量
+ if (records.length > 100) {
+ records.shift();
+ }
+
+ // 触发观察者通知
+ this.notifyObservers(name, data);
+ }
+
+ /**
+ * 开始性能计时
+ * @param {string} name - 计时器名称
+ */
+ startTimer(name) {
+ if (typeof window !== 'undefined' && window.performance) {
+ performance.mark(`${name}_start`);
+ }
+ }
+
+ /**
+ * 结束性能计时
+ * @param {string} name - 计时器名称
+ */
+ endTimer(name) {
+ if (typeof window !== 'undefined' && window.performance) {
+ performance.mark(`${name}_end`);
+ performance.measure(name, `${name}_start`, `${name}_end`);
+ }
+ }
+
+ /**
+ * 获取性能指标
+ * @param {string} name - 指标名称
+ */
+ getMetric(name) {
+ return this.metrics.get(name) || [];
+ }
+
+ /**
+ * 获取平均渲染时间
+ */
+ getAverageRenderTime() {
+ const renders = this.getMetric('template-render');
+ if (renders.length === 0) return 0;
+
+ const total = renders.reduce((sum, r) => sum + r.duration, 0);
+ return total / renders.length;
+ }
+
+ /**
+ * 添加性能观察者
+ * @param {Function} callback - 回调函数
+ */
+ addObserver(callback) {
+ this.observers.add(callback);
+ }
+
+ /**
+ * 移除性能观察者
+ * @param {Function} callback - 回调函数
+ */
+ removeObserver(callback) {
+ this.observers.delete(callback);
+ }
+
+ /**
+ * 通知观察者
+ * @param {string} name - 指标名称
+ * @param {Object} data - 指标数据
+ */
+ notifyObservers(name, data) {
+ this.observers.forEach(callback => {
+ try {
+ callback(name, data);
+ } catch (error) {
+ console.error('Performance observer error:', error);
+ }
+ });
+ }
+
+ /**
+ * 触发性能警报
+ * @param {string} type - 警报类型
+ * @param {Object} data - 警报数据
+ */
+ triggerAlert(type, data) {
+ console.warn(`Performance Alert: ${type}`, data);
+
+ // 可以在这里集成错误报告服务
+ if (window.console && window.console.warn) {
+ console.warn(`[Performance] ${type}:`, data);
+ }
+ }
+
+ /**
+ * 获取性能报告
+ */
+ getReport() {
+ const report = {
+ timestamp: Date.now(),
+ metrics: {},
+ summary: {
+ totalRequests: 0,
+ averageRenderTime: this.getAverageRenderTime(),
+ cacheHitRate: 0
+ }
+ };
+
+ // 汇总指标
+ for (const [name, records] of this.metrics) {
+ report.metrics[name] = {
+ count: records.length,
+ average: records.reduce((sum, r) => sum + (r.duration || 0), 0) / records.length,
+ min: Math.min(...records.map(r => r.duration || 0)),
+ max: Math.max(...records.map(r => r.duration || 0)),
+ last: records[records.length - 1]
+ };
+ }
+
+ return report;
+ }
+
+ /**
+ * 清空性能数据
+ */
+ clear() {
+ this.metrics.clear();
+ this.observers.clear();
+ }
+
+ /**
+ * 启用/禁用性能监控
+ * @param {boolean} enabled - 是否启用
+ */
+ setEnabled(enabled) {
+ this.isEnabled = enabled;
+ }
+}
+
+// 全局性能监控器实例
+window.performanceMonitor = new PerformanceMonitor();
+
+// 集成到模板引擎和API客户端中
+// 在template-engine.js和api-client.js中使用performanceMonitor.startTimer/endTimer
\ No newline at end of file
diff --git a/web/static/js/template-engine.js b/web/static/js/template-engine.js
new file mode 100644
index 0000000..c44cb7b
--- /dev/null
+++ b/web/static/js/template-engine.js
@@ -0,0 +1,460 @@
+/**
+ * 模板引擎 - 动态模板渲染系统
+ */
+class TemplateEngine {
+ constructor() {
+ this.templates = new Map();
+ this.cache = new Map();
+ this.currentTable = null;
+ this.currentTemplate = 'list';
+ this.currentData = null;
+ this.isLoading = false;
+ this.preloadQueue = new Set();
+ this.cacheLimit = 50; // 限制缓存大小
+ this.cacheAccess = new Map(); // 记录缓存访问时间,用于LRU
+ }
+
+ /**
+ * 加载模板
+ * @param {string} templateName - 模板名称
+ * @param {string} tableName - 表名
+ */
+ async loadTemplate(templateName, tableName) {
+ const cacheKey = `${templateName}:${tableName}`;
+
+ // 检查缓存
+ if (this.cache.has(cacheKey)) {
+ this.cacheAccess.set(cacheKey, Date.now());
+ return this.cache.get(cacheKey);
+ }
+
+ try {
+ // 使用 AbortController 支持请求取消
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
+
+ // 优先使用自定义模板
+ const customPath = `/templates/custom/${tableName}/${templateName}.html`;
+ let response = await fetch(customPath, { signal: controller.signal });
+
+ if (!response.ok) {
+ // 回退到默认自定义模板
+ const defaultPath = `/templates/custom/_default/${templateName}.html`;
+ response = await fetch(defaultPath, { signal: controller.signal });
+ }
+
+ if (!response.ok) {
+ // 回退到内置模板
+ const builtinPath = `/templates/builtin/${templateName}.html`;
+ response = await fetch(builtinPath, { signal: controller.signal });
+ }
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ throw new Error(`Template ${templateName} not found`);
+ }
+
+ const template = await response.text();
+ this.setCache(cacheKey, template);
+ return template;
+ } catch (error) {
+ console.error('Failed to load template:', error);
+ return this.getFallbackTemplate(templateName);
+ }
+ }
+
+ /**
+ * 获取回退模板
+ */
+ getFallbackTemplate(templateName) {
+ const fallbacks = {
+ 'list': `
+
+
+
+
+ {{#each columns}}
+ {{#if this.showInList}}
+
+ {{this.alias}}
+
+ {{/if}}
+ {{/each}}
+ 操作
+
+
+
+ {{#each data}}
+
+ {{#each ../columns}}
+ {{#if this.showInList}}
+
+ {{renderField (lookup ../this this.name) this.renderType this}}
+
+ {{/if}}
+ {{/each}}
+
+ 查看详情
+
+
+ {{/each}}
+
+
+
+ `,
+ 'card': `
+
+ {{#each data}}
+
+ {{#each ../columns}}
+ {{#if this.showInList}}
+
+
{{this.alias}}
+
+ {{renderField (lookup ../this this.name) this.renderType this}}
+
+
+ {{/if}}
+ {{/each}}
+
+ 查看详情
+
+
+ {{/each}}
+
+ `
+ };
+ return fallbacks[templateName] || fallbacks['list'];
+ }
+
+ /**
+ * 渲染字段
+ * @param {*} value - 字段值
+ * @param {string} type - 渲染类型
+ * @param {*} column - 列配置
+ */
+ renderField(value, type, column) {
+ const renderers = {
+ 'raw': (v) => v || '-',
+ 'time': (v) => {
+ if (!v) return '-';
+ const date = new Date(v);
+ return column.format ?
+ date.toLocaleString('zh-CN') :
+ date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ },
+ 'tag': (v, col) => {
+ if (!v || !col.values) return v || '-';
+ const tag = col.values[v.toString()];
+ if (!tag) return v;
+ return `${tag.label} `;
+ },
+ 'category': (v) => v || '-',
+ 'markdown': (v) => {
+ if (!v) return '-';
+ return marked.parse(v.toString());
+ }
+ };
+
+ return renderers[type] ? renderers[type](value, column) : value || '-';
+ }
+
+ /**
+ * 渲染模板
+ * @param {string} template - 模板内容
+ * @param {Object} data - 渲染数据
+ */
+ renderTemplate(template, data) {
+ try {
+ // 简单压缩HTML模板
+ const compressedTemplate = this.compressHTML(template);
+
+ // 简单的模板引擎实现 - 优化性能
+ let rendered = compressedTemplate;
+
+ // 预编译正则表达式,提升性能
+ const eachRegex = /\{\{#each ([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
+ const fieldRegex = /\{\{renderField ([^}]+)\}\}/g;
+ const variableRegex = /\{\{([^}]+)\}\}/g;
+ const thisRegex = /\{\{this\.([^}]+)\}\}/g;
+ const simpleThisRegex = /\{\{this\}\}/g;
+
+ // 处理循环
+ rendered = rendered.replace(eachRegex, (match, path, content) => {
+ const items = this.getValue(data, path);
+ if (!Array.isArray(items)) return '';
+
+ return items.map(item => {
+ let itemContent = content;
+ itemContent = itemContent.replace(thisRegex, (m, key) => this.getValue(item, key) || '');
+ itemContent = itemContent.replace(simpleThisRegex, item);
+ return itemContent;
+ }).join('');
+ });
+
+ // 处理字段渲染
+ rendered = rendered.replace(fieldRegex, (match, params) => {
+ const [valuePath, typePath, columnPath] = params.split(' ');
+ const value = this.getValue(data, valuePath);
+ const type = this.getValue(data, typePath);
+ const column = this.getValue(data, columnPath);
+ return this.renderField(value, type, column);
+ });
+
+ // 处理简单变量
+ rendered = rendered.replace(variableRegex, (match, path) => {
+ return this.getValue(data, path) || '';
+ });
+
+ return rendered;
+ } catch (error) {
+ console.error('Template rendering failed:', error);
+ return this.renderError('模板渲染失败:' + error.message);
+ }
+ }
+
+ /**
+ * 获取对象值
+ */
+ getValue(obj, path) {
+ if (!obj || !path) return undefined;
+
+ const keys = path.split('.');
+ let value = obj;
+
+ for (const key of keys) {
+ if (value == null) return undefined;
+ value = value[key];
+ }
+
+ return value;
+ }
+
+ /**
+ * 渲染数据表
+ * @param {string} tableName - 表名
+ * @param {Object} options - 渲染选项
+ */
+ async renderTable(tableName, options = {}) {
+ try {
+ this.showLoading();
+
+ const { page = 1, perPage = 20, search = '', sort = '', order = 'desc', template = 'list' } = options;
+
+ // 并行加载数据和模板
+ const [data, templateContent] = await Promise.all([
+ apiClient.getTableData(tableName, {
+ page,
+ per_page: perPage,
+ search,
+ sort,
+ order
+ }),
+ this.loadTemplate(template, tableName)
+ ]);
+
+ // 预加载其他常用模板
+ if (template !== 'list') {
+ this.preloadTemplates(tableName, ['list']);
+ }
+ if (template !== 'card') {
+ this.preloadTemplates(tableName, ['card']);
+ }
+
+ // 渲染数据
+ const rendered = this.renderTemplate(templateContent, {
+ data: data.data,
+ columns: data.columns || [],
+ total: data.total,
+ page: data.page,
+ perPage: data.per_page,
+ pages: data.pages
+ });
+
+ this.currentTable = tableName;
+ this.currentTemplate = template;
+ this.currentData = data;
+
+ this.hideLoading();
+ return rendered;
+
+ } catch (error) {
+ console.error('Failed to render table:', error);
+ this.hideLoading();
+ return this.renderError(error.message);
+ }
+ }
+
+ /**
+ * 切换模板
+ * @param {string} templateName - 模板名称
+ */
+ async switchTemplate(templateName) {
+ if (!this.currentTable) return;
+
+ try {
+ const rendered = await this.renderTable(this.currentTable, {
+ ...this.getCurrentOptions(),
+ template: templateName
+ });
+
+ this.updateContent(rendered);
+
+ // 更新URL参数
+ const url = new URL(window.location);
+ url.searchParams.set('template', templateName);
+ window.history.pushState({}, '', url);
+
+ } catch (error) {
+ console.error('Failed to switch template:', error);
+ }
+ }
+
+ /**
+ * 获取当前查询参数
+ */
+ getCurrentOptions() {
+ const url = new URL(window.location);
+ return {
+ page: parseInt(url.searchParams.get('page')) || 1,
+ perPage: parseInt(url.searchParams.get('per_page')) || 20,
+ search: url.searchParams.get('search') || '',
+ sort: url.searchParams.get('sort') || '',
+ order: url.searchParams.get('order') || 'desc',
+ template: this.currentTemplate
+ };
+ }
+
+ /**
+ * 显示加载状态
+ */
+ showLoading() {
+ this.isLoading = true;
+ const container = document.getElementById('mainContent');
+ if (container) {
+ container.innerHTML = `
+
+ `;
+ }
+ }
+
+ /**
+ * 隐藏加载状态
+ */
+ hideLoading() {
+ this.isLoading = false;
+ }
+
+ /**
+ * 更新内容
+ * @param {string} content - 渲染后的HTML内容
+ */
+ updateContent(content) {
+ const container = document.getElementById('mainContent');
+ if (container) {
+ container.innerHTML = content;
+ }
+ }
+
+ /**
+ * 渲染错误
+ * @param {string} message - 错误消息
+ */
+ renderError(message) {
+ return `
+
+ `;
+ }
+
+ /**
+ * 设置缓存(带LRU策略)
+ * @param {string} key - 缓存键
+ * @param {string} value - 缓存值
+ */
+ setCache(key, value) {
+ if (this.cache.size >= this.cacheLimit) {
+ // 使用LRU策略移除最少使用的缓存
+ const oldestKey = this.getOldestCacheKey();
+ if (oldestKey) {
+ this.cache.delete(oldestKey);
+ this.cacheAccess.delete(oldestKey);
+ }
+ }
+
+ this.cache.set(key, value);
+ this.cacheAccess.set(key, Date.now());
+ }
+
+ /**
+ * 获取最久未使用的缓存键
+ */
+ getOldestCacheKey() {
+ let oldestKey = null;
+ let oldestTime = Date.now();
+
+ for (const [key, time] of this.cacheAccess) {
+ if (time < oldestTime) {
+ oldestTime = time;
+ oldestKey = key;
+ }
+ }
+
+ return oldestKey;
+ }
+
+ /**
+ * 预加载模板
+ * @param {string} tableName - 表名
+ * @param {Array} templateNames - 模板名称列表
+ */
+ async preloadTemplates(tableName, templateNames) {
+ const promises = templateNames.map(templateName =>
+ this.loadTemplate(templateName, tableName).catch(() => {
+ // 预加载失败时不影响主流程
+ console.warn(`Failed to preload template: ${templateName}`);
+ })
+ );
+
+ await Promise.allSettled(promises);
+ }
+
+ /**
+ * 清空缓存
+ */
+ clearCache() {
+ this.cache.clear();
+ this.cacheAccess.clear();
+ }
+
+ /**
+ * 压缩HTML内容(简单压缩)
+ * @param {string} html - HTML内容
+ */
+ compressHTML(html) {
+ return html
+ .replace(/\s+/g, ' ')
+ .replace(/>\s+<')
+ .trim();
+ }
+}
+
+// 全局模板引擎实例
+window.templateEngine = new TemplateEngine();
\ No newline at end of file
diff --git a/web/static/js/ui-controller.js b/web/static/js/ui-controller.js
new file mode 100644
index 0000000..229beb7
--- /dev/null
+++ b/web/static/js/ui-controller.js
@@ -0,0 +1,515 @@
+/**
+ * UI控制器 - 管理模板切换和用户界面交互
+ */
+class UIController {
+ constructor() {
+ this.currentTable = null;
+ this.currentTemplate = 'list';
+ this.availableTemplates = [];
+ this.isLoading = false;
+ this.searchTimeout = null;
+
+ this.init();
+ }
+
+ /**
+ * 初始化UI控制器
+ */
+ init() {
+ this.bindEvents();
+ this.setupKeyboardShortcuts();
+ this.setupResponsiveUI();
+ this.loadFromURL();
+ }
+
+ /**
+ * 绑定事件监听器
+ */
+ bindEvents() {
+ // 表选择器
+ const tableSelector = document.getElementById('tableSelector');
+ if (tableSelector) {
+ tableSelector.addEventListener('change', (e) => {
+ this.changeTable(e.target.value);
+ });
+ }
+
+ // 搜索框
+ const searchInput = document.getElementById('searchInput');
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ this.handleSearch(e.target.value);
+ });
+ }
+
+ // 分页
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('pagination-link')) {
+ e.preventDefault();
+ const page = parseInt(e.target.dataset.page);
+ this.goToPage(page);
+ }
+ });
+
+ // 排序
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('sort-header')) {
+ e.preventDefault();
+ const field = e.target.dataset.field;
+ this.toggleSort(field);
+ }
+ });
+
+ // 模板切换
+ document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('template-switch')) {
+ e.preventDefault();
+ const template = e.target.dataset.template;
+ this.switchTemplate(template);
+ }
+ });
+
+ // 刷新按钮
+ const refreshBtn = document.getElementById('refreshBtn');
+ if (refreshBtn) {
+ refreshBtn.addEventListener('click', () => {
+ this.refreshData();
+ });
+ }
+
+ // 浏览器历史记录
+ window.addEventListener('popstate', () => {
+ this.loadFromURL();
+ });
+ }
+
+ /**
+ * 设置键盘快捷键
+ */
+ setupKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + R: 刷新
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
+ e.preventDefault();
+ this.refreshData();
+ }
+
+ // Ctrl/Cmd + K: 聚焦搜索
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault();
+ const searchInput = document.getElementById('searchInput');
+ if (searchInput) searchInput.focus();
+ }
+
+ // Escape: 关闭详情弹窗
+ if (e.key === 'Escape') {
+ this.closeModal();
+ }
+ });
+ }
+
+ /**
+ * 设置响应式UI
+ */
+ setupResponsiveUI() {
+ // 监听窗口大小变化
+ let resizeTimeout;
+ window.addEventListener('resize', () => {
+ clearTimeout(resizeTimeout);
+ resizeTimeout = setTimeout(() => {
+ this.adjustUILayout();
+ }, 250);
+ });
+ }
+
+ /**
+ * 调整UI布局
+ */
+ adjustUILayout() {
+ const width = window.innerWidth;
+ const container = document.getElementById('mainContent');
+
+ if (!container) return;
+
+ if (width < 768) {
+ container.classList.add('mobile-layout');
+ container.classList.remove('desktop-layout');
+ } else {
+ container.classList.add('desktop-layout');
+ container.classList.remove('mobile-layout');
+ }
+ }
+
+ /**
+ * 从URL加载配置
+ */
+ loadFromURL() {
+ const url = new URL(window.location);
+ const table = url.searchParams.get('table');
+ const template = url.searchParams.get('template') || 'list';
+ const page = parseInt(url.searchParams.get('page')) || 1;
+ const search = url.searchParams.get('search') || '';
+
+ if (table) {
+ this.currentTable = table;
+ this.currentTemplate = template;
+ this.loadTableData(table, { page, search, template });
+ }
+ }
+
+ /**
+ * 切换数据表
+ * @param {string} tableName - 表名
+ */
+ async changeTable(tableName) {
+ if (this.isLoading) return;
+
+ this.currentTable = tableName;
+ this.currentTemplate = 'list'; // 重置为默认模板
+
+ try {
+ // 并行获取可用模板和预加载模板
+ const [templates] = await Promise.all([
+ apiClient.getAvailableTemplates(tableName),
+ templateEngine.preloadTemplates(tableName, ['list', 'card'])
+ ]);
+
+ this.availableTemplates = templates.templates;
+ this.renderTemplateSelector();
+
+ // 加载数据
+ await this.loadTableData(tableName);
+
+ // 更新URL
+ this.updateURL();
+
+ } catch (error) {
+ console.error('Failed to change table:', error);
+ this.showError('切换数据表失败:' + error.message);
+ }
+ }
+
+ /**
+ * 切换模板
+ * @param {string} templateName - 模板名称
+ */
+ async switchTemplate(templateName) {
+ if (this.isLoading || !this.currentTable) return;
+
+ this.currentTemplate = templateName;
+
+ try {
+ await templateEngine.switchTemplate(templateName);
+ this.updateURL();
+ this.updateTemplateSelector(templateName);
+
+ } catch (error) {
+ console.error('Failed to switch template:', error);
+ this.showError('切换模板失败:' + error.message);
+ }
+ }
+
+ /**
+ * 加载数据表数据
+ * @param {string} tableName - 表名
+ * @param {Object} options - 选项
+ */
+ async loadTableData(tableName, options = {}) {
+ const {
+ page = 1,
+ perPage = 20,
+ search = '',
+ sort = '',
+ order = 'desc',
+ template = this.currentTemplate
+ } = options;
+
+ this.showLoading();
+
+ try {
+ const rendered = await templateEngine.renderTable(tableName, {
+ page,
+ perPage,
+ search,
+ sort,
+ order,
+ template
+ });
+
+ this.updateContent(rendered);
+ this.renderPagination({ page, perPage, total: templateEngine.currentData?.total || 0 });
+
+ } catch (error) {
+ console.error('Failed to load table data:', error);
+ this.showError('加载数据失败:' + error.message);
+ } finally {
+ this.hideLoading();
+ }
+ }
+
+ /**
+ * 渲染模板选择器
+ */
+ renderTemplateSelector() {
+ const container = document.getElementById('templateSelector');
+ if (!container) return;
+
+ container.innerHTML = `
+
+ 视图:
+ ${this.availableTemplates.map(template => `
+
+ ${this.getTemplateDisplayName(template)}
+
+ `).join('')}
+
+ `;
+ }
+
+ /**
+ * 更新模板选择器状态
+ * @param {string} activeTemplate - 当前激活的模板
+ */
+ updateTemplateSelector(activeTemplate) {
+ const buttons = document.querySelectorAll('.template-switch');
+ buttons.forEach(btn => {
+ if (btn.dataset.template === activeTemplate) {
+ btn.className = 'template-switch px-3 py-1 text-sm rounded bg-blue-500 text-white';
+ } else {
+ btn.className = 'template-switch px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300';
+ }
+ });
+ }
+
+ /**
+ * 获取模板显示名称
+ * @param {string} template - 模板名称
+ */
+ getTemplateDisplayName(template) {
+ const names = {
+ 'list': '列表',
+ 'card': '卡片',
+ 'timeline': '时间轴'
+ };
+ return names[template] || template;
+ }
+
+ /**
+ * 渲染分页
+ * @param {Object} pagination - 分页信息
+ */
+ renderPagination({ page, perPage, total }) {
+ const container = document.getElementById('pagination');
+ if (!container) return;
+
+ const pages = Math.ceil(total / perPage);
+ const startPage = Math.max(1, page - 2);
+ const endPage = Math.min(pages, page + 2);
+
+ let html = `
+
+
+ 共 ${total} 条记录,第 ${page} / ${pages} 页
+
+
+ `;
+
+ if (page > 1) {
+ html += `
+
+ `;
+ }
+
+ for (let i = startPage; i <= endPage; i++) {
+ html += `
+
+ `;
+ }
+
+ if (page < pages) {
+ html += `
+
+ `;
+ }
+
+ html += `
+
+
+ `;
+
+ container.innerHTML = html;
+ }
+
+ /**
+ * 处理搜索
+ * @param {string} query - 搜索关键词
+ */
+ handleSearch(query) {
+ clearTimeout(this.searchTimeout);
+
+ this.searchTimeout = setTimeout(() => {
+ if (this.currentTable) {
+ this.loadTableData(this.currentTable, { search: query, page: 1 });
+ this.updateURL();
+ }
+ }, 300);
+ }
+
+ /**
+ * 跳转到指定页面
+ * @param {number} page - 页码
+ */
+ goToPage(page) {
+ if (this.currentTable) {
+ this.loadTableData(this.currentTable, { page });
+ this.updateURL();
+ }
+ }
+
+ /**
+ * 切换排序
+ * @param {string} field - 排序字段
+ */
+ toggleSort(field) {
+ const url = new URL(window.location);
+ const currentSort = url.searchParams.get('sort');
+ const currentOrder = url.searchParams.get('order') || 'asc';
+
+ let newOrder = 'asc';
+ if (currentSort === field && currentOrder === 'asc') {
+ newOrder = 'desc';
+ }
+
+ if (this.currentTable) {
+ this.loadTableData(this.currentTable, {
+ sort: field,
+ order: newOrder,
+ page: 1
+ });
+ this.updateURL();
+ }
+ }
+
+ /**
+ * 刷新数据
+ */
+ async refreshData() {
+ if (this.currentTable) {
+ templateEngine.clearCache();
+ await this.loadTableData(this.currentTable);
+ }
+ }
+
+ /**
+ * 更新URL参数
+ */
+ updateURL() {
+ const url = new URL(window.location);
+ url.searchParams.set('table', this.currentTable);
+ url.searchParams.set('template', this.currentTemplate);
+
+ const currentOptions = this.getCurrentOptions();
+ if (currentOptions.page > 1) {
+ url.searchParams.set('page', currentOptions.page);
+ } else {
+ url.searchParams.delete('page');
+ }
+
+ if (currentOptions.search) {
+ url.searchParams.set('search', currentOptions.search);
+ } else {
+ url.searchParams.delete('search');
+ }
+
+ window.history.pushState({}, '', url);
+ }
+
+ /**
+ * 显示加载状态
+ */
+ showLoading() {
+ this.isLoading = true;
+ const container = document.getElementById('mainContent');
+ if (container) {
+ container.innerHTML = `
+
+ `;
+ }
+ }
+
+ /**
+ * 隐藏加载状态
+ */
+ hideLoading() {
+ this.isLoading = false;
+ }
+
+ /**
+ * 更新内容
+ * @param {string} content - 内容HTML
+ */
+ updateContent(content) {
+ const container = document.getElementById('mainContent');
+ if (container) {
+ container.innerHTML = content;
+ }
+ }
+
+ /**
+ * 显示错误消息
+ * @param {string} message - 错误消息
+ */
+ showError(message) {
+ const container = document.getElementById('mainContent');
+ if (container) {
+ container.innerHTML = `
+
+ `;
+ }
+ }
+
+ /**
+ * 关闭模态框
+ */
+ closeModal() {
+ const modal = document.getElementById('detailModal');
+ if (modal) {
+ modal.classList.add('hidden');
+ }
+ }
+}
+
+// 全局UI控制器实例
+window.uiController = new UIController();
\ No newline at end of file
diff --git a/web/templates/builtin/complete.html b/web/templates/builtin/complete.html
new file mode 100644
index 0000000..6acfe6d
--- /dev/null
+++ b/web/templates/builtin/complete.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ {{.TableAlias}} - 数据管理
+
+
+
+
+
{{.TableAlias}}
+
+ {{if gt (len .Data) 0}}
+
+
+
+
+ {{range .Columns}}
+ {{if .ShowInList}}
+
+ {{.Alias}}
+
+ {{end}}
+ {{end}}
+
+
+
+ {{range $row := .Data}}
+
+ {{range $col := $.Columns}}
+ {{if $col.ShowInList}}
+
+ {{index $row $col.Name}}
+
+ {{end}}
+ {{end}}
+
+ {{end}}
+
+
+
+ {{else}}
+
+ {{end}}
+
+
+ 总计: {{.Total}} 条记录
+
+
+
+
\ No newline at end of file
diff --git a/web/templates/builtin/debug.html b/web/templates/builtin/debug.html
new file mode 100644
index 0000000..cb58d54
--- /dev/null
+++ b/web/templates/builtin/debug.html
@@ -0,0 +1,27 @@
+{{define "content"}}
+Debug Information
+Table: {{.Table}}
+TableAlias: {{.TableAlias}}
+Total: {{.Total}}
+Page: {{.Page}}
+Pages: {{.Pages}}
+Columns: {{json .Columns}}
+Data Type: {{printf "%T" .Data}}
+Data Length: {{len .Data}}
+First Row: {{json (index .Data 0)}}
+
+
+
Columns
+
+ {{range .Columns}}
+ {{.Name}} ({{.Alias}}) - Show: {{.ShowInList}}
+ {{end}}
+
+
+
+
Data
+ {{range $i, $row := .Data}}
+
Row {{$i}}: {{json $row}}
+ {{end}}
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/category.html b/web/templates/builtin/field/category.html
new file mode 100644
index 0000000..11328d8
--- /dev/null
+++ b/web/templates/builtin/field/category.html
@@ -0,0 +1,3 @@
+{{define "field-category"}}
+{{.Value}}
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/markdown.html b/web/templates/builtin/field/markdown.html
new file mode 100644
index 0000000..2c42bc4
--- /dev/null
+++ b/web/templates/builtin/field/markdown.html
@@ -0,0 +1,9 @@
+{{define "field-markdown"}}
+{{if .Value}}
+
+ {{.Value}}
+
+{{else}}
+ -
+{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/raw.html b/web/templates/builtin/field/raw.html
new file mode 100644
index 0000000..c542e28
--- /dev/null
+++ b/web/templates/builtin/field/raw.html
@@ -0,0 +1,3 @@
+{{define "field-raw"}}
+{{.Value}}
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/tag.html b/web/templates/builtin/field/tag.html
new file mode 100644
index 0000000..27a73e6
--- /dev/null
+++ b/web/templates/builtin/field/tag.html
@@ -0,0 +1,14 @@
+{{define "field-tag"}}
+{{if .Value}}
+ {{$tag := index .Column.values .Value}}
+ {{if $tag}}
+
+ {{$tag.label}}
+
+ {{else}}
+ {{.Value}}
+ {{end}}
+{{else}}
+ -
+{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/text.html b/web/templates/builtin/field/text.html
new file mode 100644
index 0000000..97a35be
--- /dev/null
+++ b/web/templates/builtin/field/text.html
@@ -0,0 +1,17 @@
+{{define "field-text"}}
+{{.Value}}
+{{end}}
+
+{{define "field"}}
+{{if eq .Type "time"}}
+ {{template "field-time" .}}
+{{else if eq .Type "tag"}}
+ {{template "field-tag" .}}
+{{else if eq .Type "markdown"}}
+ {{template "field-markdown" .}}
+{{else if eq .Type "raw"}}
+ {{template "field-raw" .}}
+{{else}}
+ {{template "field-text" .}}
+{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/field/time.html b/web/templates/builtin/field/time.html
new file mode 100644
index 0000000..70e13d0
--- /dev/null
+++ b/web/templates/builtin/field/time.html
@@ -0,0 +1,9 @@
+{{define "field-time"}}
+
+ {{if .Value}}
+ {{.Value}}
+ {{else}}
+ -
+ {{end}}
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/layout.html b/web/templates/builtin/layout.html
new file mode 100644
index 0000000..6ba142e
--- /dev/null
+++ b/web/templates/builtin/layout.html
@@ -0,0 +1,162 @@
+{{define "layout"}}
+
+
+
+
+
+ {{.Title}} - 数据管理
+
+
+
+
+
+
+
+
+
+ 选择数据表...
+ {{range .Tables}}
+
+ {{.Alias}}
+
+ {{end}}
+
+
+
+
+
+ {{template "content" .}}
+
+
+
+
+
+
+
+
+
+
+ {{template "scripts" .}}
+
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/list.html b/web/templates/builtin/list.html
new file mode 100644
index 0000000..a02a046
--- /dev/null
+++ b/web/templates/builtin/list.html
@@ -0,0 +1,128 @@
+{{define "content"}}
+
+ {{if .Data}}
+ {{if eq .TemplateType "table"}}
+ {{template "table-view" .}}
+ {{else if eq .TemplateType "card"}}
+ {{template "card-view" .}}
+ {{else}}
+ {{template "table-view" .}}
+ {{end}}
+ {{else}}
+
+ 暂无数据
+
+ {{end}}
+
+
+
+
+
+ 共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
+
+
+ {{if gt .Page 1}}
+
+ 上一页
+
+ {{end}}
+
+ {{range until .Pages}}
+ {{$page := add . 1}}
+ {{if eq $page $.Page}}
+
{{$page}}
+ {{else}}
+
+ {{$page}}
+
+ {{end}}
+ {{end}}
+
+ {{if lt .Page .Pages}}
+
+ 下一页
+
+ {{end}}
+
+
+{{end}}
+
+{{define "table-view"}}
+
+
+
+ {{range .Columns}}
+ {{if .ShowInList}}
+
+ {{.Alias}}
+ {{if .Sortable}}
+
+ ⇅
+
+ {{end}}
+
+ {{end}}
+ {{end}}
+
+ 操作
+
+
+
+
+ {{range $row := .Data}}
+
+ {{range $col := $.Columns}}
+ {{if $col.ShowInList}}
+
+ {{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
+
+ {{end}}
+ {{end}}
+
+
+ 查看详情
+
+
+
+ {{end}}
+
+
+{{end}}
+
+{{define "card-view"}}
+
+ {{range $row := .Data}}
+
+ {{range $col := $.Columns}}
+ {{if $col.ShowInList}}
+
+
{{$col.Alias}}
+
+ {{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
+
+
+ {{end}}
+ {{end}}
+
+
+
+ 查看详情
+
+
+
+ {{end}}
+
+{{end}}
+
+{{define "scripts"}}
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/builtin/simple.html b/web/templates/builtin/simple.html
new file mode 100644
index 0000000..e5bd8ed
--- /dev/null
+++ b/web/templates/builtin/simple.html
@@ -0,0 +1,33 @@
+{{define "content"}}
+
+
{{.TableAlias}} - 简易视图
+
+
+
+
+ {{range .Columns}}
+ {{if .ShowInList}}
+
+ {{.Alias}}
+
+ {{end}}
+ {{end}}
+
+
+
+ {{range $row := .Data}}
+
+ {{range $col := $.Columns}}
+ {{if $col.ShowInList}}
+
+ {{index $row $col.Name}}
+
+ {{end}}
+ {{end}}
+
+ {{end}}
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/custom/_default/card.html b/web/templates/custom/_default/card.html
new file mode 100644
index 0000000..8c1fa33
--- /dev/null
+++ b/web/templates/custom/_default/card.html
@@ -0,0 +1,46 @@
+{{define "content"}}
+
+ {{range .Data}}
+
+ {{range $.Columns}}
+ {{if .ShowInList}}
+
+
{{.Alias}}
+
+ {{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
+
+
+ {{end}}
+ {{end}}
+
+
+ 查看详情
+
+
+
+ {{end}}
+
+
+
+
+
+ 共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
+
+
+ {{if gt .Page 1}}
+
+ 上一页
+
+ {{end}}
+
+ {{if lt .Page .Pages}}
+
+ 下一页
+
+ {{end}}
+
+
+{{end}}
\ No newline at end of file
diff --git a/web/templates/custom/_default/list.html b/web/templates/custom/_default/list.html
new file mode 100644
index 0000000..1568124
--- /dev/null
+++ b/web/templates/custom/_default/list.html
@@ -0,0 +1,67 @@
+{{define "content"}}
+
+ {{if .Data}}
+
+
+
+ {{range .Columns}}
+ {{if .ShowInList}}
+
+ {{.Alias}}
+
+ {{end}}
+ {{end}}
+
+ 操作
+
+
+
+
+ {{range .Data}}
+
+ {{range $.Columns}}
+ {{if .ShowInList}}
+
+ {{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
+
+ {{end}}
+ {{end}}
+
+
+ 查看详情
+
+
+
+ {{end}}
+
+
+ {{else}}
+
+ 暂无数据
+
+ {{end}}
+
+
+
+
+
+ 共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
+
+
+ {{if gt .Page 1}}
+
+ 上一页
+
+ {{end}}
+
+ {{if lt .Page .Pages}}
+
+ 下一页
+
+ {{end}}
+
+
+{{end}}
\ No newline at end of file