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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:150 +[0.030ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.180ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:12:18 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.026ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.047ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:12:18 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.032ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:12:18 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.032ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:12:18 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.031ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.096ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:12:23 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.025ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.090ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:15:58 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.021ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.033ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:15:58 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.028ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:15:58 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:15:58 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.026ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.272ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:22:48 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.045ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.077ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:22:48 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.063ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:22:48 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.059ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:22:48 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.059ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.104ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:22:59 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.026ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.043ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:22:59 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.037ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:22:59 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.037ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:22:59 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.035ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.142ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.047ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.059ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:23:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.028ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.028ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:23:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.026ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.096ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:15 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.026ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.052ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:23:15 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.039ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:15 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.038ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:23:15 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.035ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.098ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:54 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.020ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.031ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:23:54 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:23:54 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.026ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:23:54 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.093ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:26:00 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.019ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.030ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:26:00 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:26:00 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.025ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:26:00 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.025ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.113ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:26:04 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.028ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.047ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:26:04 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.041ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:26:04 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.041ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:26:04 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.039ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.087ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:27:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.019ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.030ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:27:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:27:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.025ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:27:10 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.025ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.078ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:27:23 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.020ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.032ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:27:23 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:27:23 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:27:23 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.025ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.090ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:27:41 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.020ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.033ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:27:41 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.031ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:27:41 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.031ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:27:41 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.030ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.096ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:28:56 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.035ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.056ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:28:56 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.039ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:28:56 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.032ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:28:56 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.029ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.188ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:30:46 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.042ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.079ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:30:46 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.069ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:30:46 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.073ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:30:46 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.069ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:313 +[0.086ms] [rows:1] SELECT COUNT(*) FROM categories + +2025/08/07 19:31:03 /Users/rogee/Projects/self/database_render/internal/database/connection.go:351 +[0.020ms] [rows:-] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.033ms] [rows:1] SELECT COUNT(*) FROM users + +2025/08/07 19:31:03 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.029ms] [rows:1] SELECT COUNT(*) FROM comments + +2025/08/07 19:31:03 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.042ms] [rows:1] SELECT COUNT(*) FROM posts + +2025/08/07 19:31:03 /Users/rogee/Projects/self/database_render/internal/database/connection.go:419 +[0.027ms] [rows:1] 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 /Users/rogee/Projects/self/database_render/internal/database/connection.go:150 +[0.037ms] [rows:-] 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}} + + {{/if}} + {{/each}} + + + + + {{#each data}} + + {{#each ../columns}} + {{#if this.showInList}} + + {{/if}} + {{/each}} + + + {{/each}} + +
+ {{this.alias}} + 操作
+ {{renderField (lookup ../this this.name) this.renderType this}} + + +
+
+ `, + 'card': ` +
+ {{#each data}} +
+ {{#each ../columns}} + {{#if this.showInList}} +
+ +
+ {{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 ` +
+
+
+ + + +
+
+

加载失败

+
+

${message}

+
+
+
+
+ `; + } + + /** + * 设置缓存(带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 => ` + + `).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 = ` +
+
+
+ + + +
+
+

错误

+
+

${message}

+
+
+
+
+ `; + } + } + + /** + * 关闭模态框 + */ + 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}} + + {{end}} + {{end}} + + + + {{range $row := .Data}} + + {{range $col := $.Columns}} + {{if $col.ShowInList}} + + {{end}} + {{end}} + + {{end}} + +
+ {{.Alias}} +
+ {{index $row $col.Name}} +
+
+ {{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

+ +
+
+

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}} - 数据管理 + + + + + +
+ +
+ +
+ + +
+ {{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}} + + {{end}} + {{end}} + + + + + {{range $row := .Data}} + + {{range $col := $.Columns}} + {{if $col.ShowInList}} + + {{end}} + {{end}} + + + {{end}} + +
+ {{.Alias}} + {{if .Sortable}} + + {{end}} + + 操作 +
+ {{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}} + + +
+{{end}} + +{{define "card-view"}} +
+ {{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 "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}} + + {{end}} + {{end}} + + + + {{range $row := .Data}} + + {{range $col := $.Columns}} + {{if $col.ShowInList}} + + {{end}} + {{end}} + + {{end}} + +
+ {{.Alias}} +
+ {{index $row $col.Name}} +
+
+
+{{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}} +
+ +
+ {{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}} + + {{end}} + {{end}} + + + + + {{range .Data}} + + {{range $.Columns}} + {{if .ShowInList}} + + {{end}} + {{end}} + + + {{end}} + +
+ {{.Alias}} + + 操作 +
+ {{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}} + + +
+ {{else}} +
+ 暂无数据 +
+ {{end}} +
+ + +
+
+ 共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页 +
+
+ {{if gt .Page 1}} + + 上一页 + + {{end}} + + {{if lt .Page .Pages}} + + 下一页 + + {{end}} +
+
+{{end}} \ No newline at end of file