feat: update auto render

This commit is contained in:
2025-08-07 20:03:53 +08:00
parent fd67864247
commit d7e8ca38f8
39 changed files with 5684 additions and 130 deletions

View File

@@ -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)

160
cmd/template_system/main.go Normal file
View File

@@ -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)
})
}

View File

@@ -1,147 +1,215 @@
# Database Render Application Configuration
# Copy this file to config.yaml and modify as needed
app:
name: "Database Render"
port: 8080
debug: false
# 数据库动态渲染系统 - 配置文件示例
# 支持数据库类型: sqlite, mysql, postgres
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: "创建时间"
type: "datetime"
render_type: "time"
- name: "updated_at"
alias: "更新时间"
alias: "发布时间"
type: "datetime"
render_type: "time"
format: "2006-01-02 15:04:05"
- name: "users"
alias: "用户管理"
description: "系统用户管理"
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

215
config/templates.yaml Normal file
View File

@@ -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

332
debug.log Normal file
View File

@@ -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

99
debug/debug_render.go Normal file
View File

@@ -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!")
}

59
debug/debug_template.go Normal file
View File

@@ -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)
}
}

329
docs/template_system.md Normal file
View File

@@ -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
<!-- web/templates/builtin/field/new_type.html -->
{{define "field_new_type"}}
<div class="field-new-type">
{{.Value}}
</div>
{{end}}
```
2. **注册字段类型**
```yaml
field_types:
new_type:
type: "custom"
renderer: "new_type"
```
#### 添加新的模板类型
1. **创建模板文件**
```html
<!-- web/templates/builtin/new_type.html -->
{{template "layout" .}}
```
2. **注册模板类型**
```yaml
template_types:
new_type:
layout: "new_layout"
fields: {}
```
### 贡献指南
欢迎提交PR和Issue
1. Fork项目
2. 创建特性分支
3. 添加测试
4. 提交PR
## 许可证
MIT License

2
go.mod
View File

@@ -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

View File

@@ -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
}

View File

@@ -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(),
}
}

View File

@@ -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"
)
@@ -220,13 +221,65 @@ func (r *Renderer) RenderList(c fiber.Ctx, tableName string) error {
"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
// 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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -1,8 +0,0 @@
## 关于自定义模板的说明
1. 系统全局存在主模板,这个模板是固定的不可变更的,不受任何表数据的影响
2. 数据列表
2.1 数据列表数据来源于后端 ajax 请求的 json 数据列表。不使用后端渲染全量模板后在前端替换指定区域。
2.2 对于配置中未定义使用自定义模板的,使用系统默的认列表模板(默认 card进行渲染数据列表
2.3 不再需要切换 table/card 模式功能
2.4 不需要导出功能

BIN
server.log Normal file

Binary file not shown.

11
server_debug.log Normal file
View File

@@ -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

2
template_output.html Normal file
View File

@@ -0,0 +1,2 @@
Testing template render...
Error executing template: html/template:test:153:15: no such template "scripts"

186
upgrade.md Normal file
View File

@@ -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"}}
<div class="custom-list">
{{range .Data}}
<div class="item">{{template "field" dict "Value" .Title "Type" "raw"}}</div>
{{end}}
</div>
{{end}}
```
### JavaScript API
```javascript
// 模板系统API
TemplateSystem.load(tableName, templateType);
TemplateSystem.render(data, template);
TemplateSystem.reloadCache();
```
## 迁移指南
### 从旧版本迁移
1. 移除所有服务端模板渲染代码
2. 将现有模板迁移到新文件结构
3. 更新配置文件格式
4. 测试自定义模板兼容性

180
web/static/js/api-client.js Normal file
View File

@@ -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();

View File

@@ -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

View File

@@ -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': `
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{#each columns}}
{{#if this.showInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{this.alias}}
</th>
{{/if}}
{{/each}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{#each data}}
<tr class="hover:bg-gray-50">
{{#each ../columns}}
{{#if this.showInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{renderField (lookup ../this this.name) this.renderType this}}
</td>
{{/if}}
{{/each}}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="showDetail('{{this.id}}')" class="text-blue-600 hover:text-blue-900">查看详情</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
`,
'card': `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{{#each data}}
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
{{#each ../columns}}
{{#if this.showInList}}
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700">{{this.alias}}</label>
<div class="mt-1 text-sm text-gray-900">
{{renderField (lookup ../this this.name) this.renderType this}}
</div>
</div>
{{/if}}
{{/each}}
<div class="mt-4">
<button onclick="showDetail('{{this.id}}')" class="text-blue-600 hover:text-blue-900 text-sm">查看详情</button>
</div>
</div>
{{/each}}
</div>
`
};
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 `<span class="px-2 py-1 text-xs font-medium rounded-full" style="background-color: ${tag.color}; color: white;">${tag.label}</span>`;
},
'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 = `
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">加载中...</span>
</div>
`;
}
}
/**
* 隐藏加载状态
*/
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 `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="text-red-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">加载失败</h3>
<div class="mt-2 text-sm text-red-700">
<p>${message}</p>
</div>
</div>
</div>
</div>
`;
}
/**
* 设置缓存带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+</g, '><')
.trim();
}
}
// 全局模板引擎实例
window.templateEngine = new TemplateEngine();

View File

@@ -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 = `
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">视图:</span>
${this.availableTemplates.map(template => `
<button
class="template-switch px-3 py-1 text-sm rounded ${
template === this.currentTemplate
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
data-template="${template}"
>
${this.getTemplateDisplayName(template)}
</button>
`).join('')}
</div>
`;
}
/**
* 更新模板选择器状态
* @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 = `
<div class="flex justify-between items-center">
<div class="text-sm text-gray-700">
${total} 条记录,第 ${page} / ${pages}
</div>
<div class="flex space-x-2">
`;
if (page > 1) {
html += `
<button class="pagination-link px-3 py-1 text-sm border rounded hover:bg-gray-50" data-page="${page - 1}">
上一页
</button>
`;
}
for (let i = startPage; i <= endPage; i++) {
html += `
<button class="pagination-link px-3 py-1 text-sm border rounded ${
i === page ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'
}" data-page="${i}">
${i}
</button>
`;
}
if (page < pages) {
html += `
<button class="pagination-link px-3 py-1 text-sm border rounded hover:bg-gray-50" data-page="${page + 1}">
下一页
</button>
`;
}
html += `
</div>
</div>
`;
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 = `
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">加载中...</span>
</div>
`;
}
}
/**
* 隐藏加载状态
*/
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 = `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="text-red-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">错误</h3>
<div class="mt-2 text-sm text-red-700">
<p>${message}</p>
</div>
</div>
</div>
</div>
`;
}
}
/**
* 关闭模态框
*/
closeModal() {
const modal = document.getElementById('detailModal');
if (modal) {
modal.classList.add('hidden');
}
}
}
// 全局UI控制器实例
window.uiController = new UIController();

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.TableAlias}} - 数据管理</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">{{.TableAlias}}</h1>
{{if gt (len .Data) 0}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{range .Columns}}
{{if .ShowInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{{.Alias}}
</th>
{{end}}
{{end}}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range $row := .Data}}
<tr class="hover:bg-gray-50">
{{range $col := $.Columns}}
{{if $col.ShowInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{index $row $col.Name}}
</td>
{{end}}
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-8">
<p class="text-gray-500">暂无数据</p>
</div>
{{end}}
<div class="mt-4 text-sm text-gray-600">
总计: {{.Total}} 条记录
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
{{define "content"}}
<h1>Debug Information</h1>
<pre>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)}}
</pre>
<div>
<h2>Columns</h2>
<ul>
{{range .Columns}}
<li>{{.Name}} ({{.Alias}}) - Show: {{.ShowInList}}</li>
{{end}}
</ul>
</div>
<div>
<h2>Data</h2>
{{range $i, $row := .Data}}
<div>Row {{$i}}: {{json $row}}</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "field-category"}}
<span class="text-gray-900">{{.Value}}</span>
{{end}}

View File

@@ -0,0 +1,9 @@
{{define "field-markdown"}}
{{if .Value}}
<div class="prose prose-sm max-w-none">
{{.Value}}
</div>
{{else}}
<span class="text-gray-400">-</span>
{{end}}
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "field-raw"}}
<span class="text-gray-900">{{.Value}}</span>
{{end}}

View File

@@ -0,0 +1,14 @@
{{define "field-tag"}}
{{if .Value}}
{{$tag := index .Column.values .Value}}
{{if $tag}}
<span class="px-2 py-1 text-xs font-medium rounded-full" style="background-color: {{$tag.color}}; color: white;">
{{$tag.label}}
</span>
{{else}}
<span class="text-gray-900">{{.Value}}</span>
{{end}}
{{else}}
<span class="text-gray-400">-</span>
{{end}}
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "field-text"}}
<span class="text-gray-900">{{.Value}}</span>
{{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}}

View File

@@ -0,0 +1,9 @@
{{define "field-time"}}
<span class="text-gray-900">
{{if .Value}}
{{.Value}}
{{else}}
-
{{end}}
</span>
{{end}}

View File

@@ -0,0 +1,162 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - 数据管理</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
.tag {
@apply inline-block px-2 py-1 text-xs font-medium rounded-full;
}
.modal {
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden;
}
.modal-content {
@apply bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto;
}
.loading {
@apply animate-pulse bg-gray-200 rounded;
}
</style>
</head>
<body class="bg-gray-50">
<div class="container mx-auto px-4 py-8">
<!-- 表选择器 -->
<div class="mb-6">
<select id="tableSelector" class="border rounded px-3 py-2" onchange="changeTable(this.value)">
<option value="">选择数据表...</option>
{{range .Tables}}
<option value="{{.Alias}}" {{if eq .Alias $.CurrentTable}}selected{{end}}>
{{.Alias}}
</option>
{{end}}
</select>
</div>
<!-- 主要内容区域 -->
<div id="mainContent">
{{template "content" .}}
</div>
</div>
<!-- 详情弹窗 -->
<div id="detailModal" class="modal">
<div class="modal-content">
<div class="p-6">
<h3 class="text-lg font-medium mb-4">详情信息</h3>
<div id="detailContent"></div>
<div class="mt-4 flex justify-end">
<button onclick="closeModal()" class="px-4 py-2 bg-gray-300 rounded">
关闭
</button>
</div>
</div>
</div>
</div>
<!-- 通用JavaScript -->
<script>
// 全局配置
const CONFIG = {
apiBase: '/api',
templates: {
builtin: '/templates/builtin',
custom: '/templates/custom'
}
};
// 模板系统核心
class TemplateSystem {
static async loadTemplate(path) {
const response = await fetch(path);
return response.text();
}
static async render(data, templateName, tableName) {
// 优先使用自定义模板,回退到默认模板
const customPath = `${CONFIG.templates.custom}/${tableName}/${templateName}.html`;
const defaultPath = `${CONFIG.templates.builtin}/${templateName}.html`;
try {
return await this.loadTemplate(customPath);
} catch {
return await this.loadTemplate(defaultPath);
}
}
static async renderField(value, type, column) {
const fieldMap = {
'raw': v => v,
'time': v => new Date(v).toLocaleString('zh-CN'),
'tag': (v, col) => {
const tag = col.values[v];
return tag ? `<span class="tag" style="background-color: ${tag.color}; color: white;">${tag.label}</span>` : v;
},
'markdown': v => marked.parse(v || ''),
'category': v => v
};
return fieldMap[type]?.(value, column) || value;
}
}
// 数据加载器
class DataLoader {
static async loadTableData(tableAlias, page = 1, perPage = 20, search = '', sort = '', order = 'desc') {
const params = new URLSearchParams({
page,
per_page: perPage,
search,
sort,
order
});
const response = await fetch(`${CONFIG.apiBase}/data/${tableAlias}?${params}`);
return response.json();
}
static async loadTableDetail(tableAlias, id) {
const response = await fetch(`${CONFIG.apiBase}/data/${tableAlias}/detail/${id}`);
return response.json();
}
}
// 通用函数
function changeTable(tableAlias) {
if (!tableAlias) return;
window.location.href = `/?table=${tableAlias}&page=1`;
}
function closeModal() {
document.getElementById('detailModal').classList.add('hidden');
}
function showLoading() {
document.getElementById('mainContent').innerHTML =
'<div class="text-center py-8"><div class="loading h-4 w-32 mx-auto"></div></div>';
}
// 初始化
document.addEventListener('DOMContentLoaded', function() {
// 绑定全局事件
window.closeModal = closeModal;
window.changeTable = changeTable;
window.TemplateSystem = TemplateSystem;
window.DataLoader = DataLoader;
});
</script>
<!-- 页面特定内容占位符 -->
{{template "scripts" .}}
<!-- 性能监控JS -->
<script src="/static/js/performance-monitor.js"></script>
<script src="/static/js/api-client.js"></script>
<script src="/static/js/template-engine.js"></script>
<script src="/static/js/ui-controller.js"></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,128 @@
{{define "content"}}
<div class="bg-white rounded-lg shadow overflow-hidden">
{{if .Data}}
{{if eq .TemplateType "table"}}
{{template "table-view" .}}
{{else if eq .TemplateType "card"}}
{{template "card-view" .}}
{{else}}
{{template "table-view" .}}
{{end}}
{{else}}
<div class="text-center py-8 text-gray-500">
暂无数据
</div>
{{end}}
</div>
<!-- 分页 -->
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-700">
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
</div>
<div class="flex space-x-2">
{{if gt .Page 1}}
<a href="?table={{.Table}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
上一页
</a>
{{end}}
{{range until .Pages}}
{{$page := add . 1}}
{{if eq $page $.Page}}
<span class="px-3 py-1 bg-blue-500 text-white rounded text-sm">{{$page}}</span>
{{else}}
<a href="?table={{$.Table}}&page={{$page}}&per_page={{$.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
{{$page}}
</a>
{{end}}
{{end}}
{{if lt .Page .Pages}}
<a href="?table={{$.Table}}&page={{add $.Page 1}}&per_page={{$.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
下一页
</a>
{{end}}
</div>
</div>
{{end}}
{{define "table-view"}}
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{range .Columns}}
{{if .ShowInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{.Alias}}
{{if .Sortable}}
<button onclick="sortBy('{{.Name}}')" class="ml-1 text-gray-400 hover:text-gray-600">
</button>
{{end}}
</th>
{{end}}
{{end}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range $row := .Data}}
<tr class="hover:bg-gray-50">
{{range $col := $.Columns}}
{{if $col.ShowInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
</td>
{{end}}
{{end}}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="showDetail('{{index $row "id"}}')"
class="text-blue-600 hover:text-blue-900">
查看详情
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{define "card-view"}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{{range $row := .Data}}
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
{{range $col := $.Columns}}
{{if $col.ShowInList}}
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700">{{$col.Alias}}</label>
<div class="mt-1 text-sm text-gray-900">
{{template "field" dict "Value" (index $row $col.Name) "Type" $col.RenderType "Column" $col}}
</div>
</div>
{{end}}
{{end}}
<div class="mt-4">
<button onclick="showDetail('{{index $row "id"}}')"
class="text-blue-600 hover:text-blue-900 text-sm">
查看详情
</button>
</div>
</div>
{{end}}
</div>
{{end}}
{{define "scripts"}}
<!-- 列表页面特定脚本 -->
<script>
// 列表页面特定功能可以在这里添加
console.log('List template loaded for:', '{{.Table}}');
</script>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "content"}}
<div class="p-4">
<h1 class="text-2xl font-bold mb-4">{{.TableAlias}} - 简易视图</h1>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{range .Columns}}
{{if .ShowInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{.Alias}}
</th>
{{end}}
{{end}}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range $row := .Data}}
<tr class="hover:bg-gray-50">
{{range $col := $.Columns}}
{{if $col.ShowInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{index $row $col.Name}}
</td>
{{end}}
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,46 @@
{{define "content"}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{{range .Data}}
<div class="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
{{range $.Columns}}
{{if .ShowInList}}
<div class="mb-2">
<label class="block text-sm font-medium text-gray-700">{{.Alias}}</label>
<div class="mt-1 text-sm text-gray-900">
{{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
</div>
</div>
{{end}}
{{end}}
<div class="mt-4">
<button onclick="showDetail('{{.id}}')"
class="text-blue-600 hover:text-blue-900 text-sm">
查看详情
</button>
</div>
</div>
{{end}}
</div>
<!-- 分页 -->
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-700">
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
</div>
<div class="flex space-x-2">
{{if gt .Page 1}}
<a href="?table={{.CurrentTable}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
上一页
</a>
{{end}}
{{if lt .Page .Pages}}
<a href="?table={{.CurrentTable}}&page={{add .Page 1}}&per_page={{.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
下一页
</a>
{{end}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,67 @@
{{define "content"}}
<div class="bg-white rounded-lg shadow overflow-hidden">
{{if .Data}}
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{{range .Columns}}
{{if .ShowInList}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{.Alias}}
</th>
{{end}}
{{end}}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{{range .Data}}
<tr class="hover:bg-gray-50">
{{range $.Columns}}
{{if .ShowInList}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{template "field" dict "Value" (index $.Data .Name) "Type" .RenderType "Column" .}}
</td>
{{end}}
{{end}}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="showDetail('{{.id}}')"
class="text-blue-600 hover:text-blue-900">
查看详情
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="text-center py-8 text-gray-500">
暂无数据
</div>
{{end}}
</div>
<!-- 分页 -->
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-700">
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
</div>
<div class="flex space-x-2">
{{if gt .Page 1}}
<a href="?table={{.CurrentTable}}&page={{sub .Page 1}}&per_page={{.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
上一页
</a>
{{end}}
{{if lt .Page .Pages}}
<a href="?table={{.CurrentTable}}&page={{add .Page 1}}&per_page={{.PerPage}}"
class="px-3 py-1 border rounded text-sm hover:bg-gray-50">
下一页
</a>
{{end}}
</div>
</div>
{{end}}