feat: update auto render
This commit is contained in:
@@ -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
160
cmd/template_system/main.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
215
config/templates.yaml
Normal 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
332
debug.log
Normal 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 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:150
|
||||
[0m[33m[0.030ms] [34;1m[rows:-][0m SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
{"time":"2025-08-07T19:12:06.757188+08:00","level":"INFO","msg":"table configuration validation completed","component":"repository","configured_tables":4,"database_tables":7}
|
||||
{"time":"2025-08-07T19:12:06.757206+08:00","level":"INFO","msg":"configuration validation completed successfully","component":"service"}
|
||||
{"time":"2025-08-07T19:12:06.757775+08:00","level":"INFO","msg":"templates loaded successfully","component":"renderer"}
|
||||
{"time":"2025-08-07T19:12:06.757905+08:00","level":"INFO","msg":"starting server","port":9080}
|
||||
|
||||
_______ __
|
||||
/ ____(_) /_ ___ _____
|
||||
/ /_ / / __ \/ _ \/ ___/
|
||||
/ __/ / / /_/ / __/ /
|
||||
/_/ /_/_.___/\___/_/ v3.0.0-beta.5
|
||||
--------------------------------------------------
|
||||
INFO Server started on: http://127.0.0.1:9080 (bound on host 0.0.0.0 and port 9080)
|
||||
INFO Total handlers count: 13
|
||||
INFO Prefork: Disabled
|
||||
INFO PID: 69870
|
||||
INFO Total process count: 1
|
||||
|
||||
{"time":"2025-08-07T19:12:12.854822+08:00","level":"ERROR","msg":"request error","component":"renderer","error":"Method Not Allowed","code":405}
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.180ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:12:18.563339+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.047ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:12:18 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
{"time":"2025-08-07T19:12:18.565744+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:12:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:12:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.025ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:12:23.315843+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.090ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.021ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:15:58.067487+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:15:58 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
{"time":"2025-08-07T19:15:58.068949+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.272ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.045ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:22:48.7903+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.077ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.063ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:22:48 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
{"time":"2025-08-07T19:22:48.7939+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
{"time":"2025-08-07T19:22:53.621014+08:00","level":"ERROR","msg":"request error","component":"renderer","error":"Method Not Allowed","code":405}
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.104ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:22:59.674368+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.043ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.037ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.037ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:22:59 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.035ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:22:59.676229+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.142ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.047ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:23:10.790845+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.059ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.028ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:23:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
{"time":"2025-08-07T19:23:10.792361+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.026ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:23:15.522006+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.052ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.038ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:23:15 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.035ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
{"time":"2025-08-07T19:23:15.533325+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.098ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:23:54.994308+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.026ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:23:54 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
{"time":"2025-08-07T19:23:54.995737+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.093ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.019ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:26:00.510105+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:26:00 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
{"time":"2025-08-07T19:26:00.511486+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.113ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.028ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:26:04.490083+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.047ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.041ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.041ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:26:04 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:26:04.492225+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.087ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.019ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:27:10.826183+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:27:10 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:27:10.827789+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.078ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:27:23.594756+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:27:23 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.025ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:27:23.596263+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.090ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:27:41.788787+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.031ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:27:41 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.030ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
{"time":"2025-08-07T19:27:41.790944+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.096ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.035ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:28:56.754001+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.056ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.039ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.032ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:28:56 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.029ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:28:56.755768+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.188ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.042ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:30:46.618528+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.079ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.069ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.073ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:30:46 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.069ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:30:46.621714+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:313
|
||||
[0m[33m[0.086ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:351
|
||||
[0m[33m[0.020ms] [34;1m[rows:-][0m SELECT * FROM categories LIMIT 10 OFFSET 0
|
||||
{"time":"2025-08-07T19:31:03.333415+08:00","level":"INFO","msg":"retrieved table data","component":"service","table":"categories","page":1,"pageSize":10,"total":6,"pages":1}
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.033ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM users
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.029ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM comments
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.042ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM posts
|
||||
|
||||
2025/08/07 19:31:03 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:419
|
||||
[0m[33m[0.027ms] [34;1m[rows:1][0m SELECT COUNT(*) FROM categories
|
||||
{"time":"2025-08-07T19:31:03.334911+08:00","level":"INFO","msg":"template configuration loaded successfully","component":"template_config","config_file":"/Users/rogee/Projects/self/database_render/config/templates.yaml","templates_count":4,"types_count":0}
|
||||
signal: killed
|
||||
99
debug/debug_render.go
Normal file
99
debug/debug_render.go
Normal 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
59
debug/debug_template.go
Normal 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
329
docs/template_system.md
Normal 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
2
go.mod
@@ -3,6 +3,7 @@ module github.com/rogeecn/database_render
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.5
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
@@ -14,7 +15,6 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.13 // indirect
|
||||
|
||||
247
internal/template/config_loader.go
Normal file
247
internal/template/config_loader.go
Normal 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
|
||||
}
|
||||
178
internal/template/hot_reload.go
Normal file
178
internal/template/hot_reload.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
114
internal/template/renderer_v2.go
Normal file
114
internal/template/renderer_v2.go
Normal 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
|
||||
}
|
||||
373
internal/template/template_loader.go
Normal file
373
internal/template/template_loader.go
Normal 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)
|
||||
}
|
||||
181
internal/template/validator.go
Normal file
181
internal/template/validator.go
Normal 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,
|
||||
}
|
||||
}
|
||||
996
internal/template/version_manager.go
Normal file
996
internal/template/version_manager.go
Normal 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
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
## 关于自定义模板的说明
|
||||
|
||||
1. 系统全局存在主模板,这个模板是固定的不可变更的,不受任何表数据的影响
|
||||
2. 数据列表
|
||||
2.1 数据列表数据来源于后端 ajax 请求的 json 数据列表。不使用后端渲染全量模板后在前端替换指定区域。
|
||||
2.2 对于配置中未定义使用自定义模板的,使用系统默的认列表模板(默认 card)进行渲染数据列表
|
||||
2.3 不再需要切换 table/card 模式功能
|
||||
2.4 不需要导出功能
|
||||
BIN
server.log
Normal file
BIN
server.log
Normal file
Binary file not shown.
11
server_debug.log
Normal file
11
server_debug.log
Normal 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 [32m/Users/rogee/Projects/self/database_render/internal/database/connection.go:150
|
||||
[0m[33m[0.037ms] [34;1m[rows:-][0m SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
{"time":"2025-08-07T19:01:25.40895+08:00","level":"INFO","msg":"table configuration validation completed","component":"repository","configured_tables":4,"database_tables":7}
|
||||
{"time":"2025-08-07T19:01:25.408971+08:00","level":"INFO","msg":"configuration validation completed successfully","component":"service"}
|
||||
{"time":"2025-08-07T19:01:25.409626+08:00","level":"INFO","msg":"templates loaded successfully","component":"renderer"}
|
||||
{"time":"2025-08-07T19:01:25.409841+08:00","level":"INFO","msg":"starting server","port":9080}
|
||||
{"time":"2025-08-07T19:01:25.410141+08:00","level":"ERROR","msg":"server error","error":"failed to listen: failed to listen: listen tcp4 :9080: bind: address already in use"}
|
||||
exit status 1
|
||||
2
template_output.html
Normal file
2
template_output.html
Normal 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
186
upgrade.md
Normal 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
180
web/static/js/api-client.js
Normal 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();
|
||||
233
web/static/js/performance-monitor.js
Normal file
233
web/static/js/performance-monitor.js
Normal 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
|
||||
460
web/static/js/template-engine.js
Normal file
460
web/static/js/template-engine.js
Normal 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();
|
||||
515
web/static/js/ui-controller.js
Normal file
515
web/static/js/ui-controller.js
Normal 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();
|
||||
53
web/templates/builtin/complete.html
Normal file
53
web/templates/builtin/complete.html
Normal 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>
|
||||
27
web/templates/builtin/debug.html
Normal file
27
web/templates/builtin/debug.html
Normal 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}}
|
||||
3
web/templates/builtin/field/category.html
Normal file
3
web/templates/builtin/field/category.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{{define "field-category"}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
9
web/templates/builtin/field/markdown.html
Normal file
9
web/templates/builtin/field/markdown.html
Normal 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}}
|
||||
3
web/templates/builtin/field/raw.html
Normal file
3
web/templates/builtin/field/raw.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{{define "field-raw"}}
|
||||
<span class="text-gray-900">{{.Value}}</span>
|
||||
{{end}}
|
||||
14
web/templates/builtin/field/tag.html
Normal file
14
web/templates/builtin/field/tag.html
Normal 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}}
|
||||
17
web/templates/builtin/field/text.html
Normal file
17
web/templates/builtin/field/text.html
Normal 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}}
|
||||
9
web/templates/builtin/field/time.html
Normal file
9
web/templates/builtin/field/time.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{define "field-time"}}
|
||||
<span class="text-gray-900">
|
||||
{{if .Value}}
|
||||
{{.Value}}
|
||||
{{else}}
|
||||
-
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
162
web/templates/builtin/layout.html
Normal file
162
web/templates/builtin/layout.html
Normal 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}}
|
||||
128
web/templates/builtin/list.html
Normal file
128
web/templates/builtin/list.html
Normal 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}}
|
||||
33
web/templates/builtin/simple.html
Normal file
33
web/templates/builtin/simple.html
Normal 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}}
|
||||
46
web/templates/custom/_default/card.html
Normal file
46
web/templates/custom/_default/card.html
Normal 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}}
|
||||
67
web/templates/custom/_default/list.html
Normal file
67
web/templates/custom/_default/list.html
Normal 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}}
|
||||
Reference in New Issue
Block a user