feat(theme): 管理后台新增模板选择器,支持运行时动态切换主题
Some checks failed
CI / Lint + Build (push) Has been cancelled

新增功能:
- 主题发现模块:扫描 templates/ 目录下含 theme.json 的目录
- 站点设置新增 site.theme 键,DB 持久化 + 内存缓存
- 运行时切换主题:保存后即时重载 Gin 模板 + 静态资源哈希
- 管理后台 '外观主题' 页面:列表展示、选择、一键切换
- 动态静态资源服务:根据当前主题目录提供 CSS/JS

修复:
- account.html 残留孤儿 {{end}} 模板语法错误
- 动态静态资源 handler 误判 Gin *filepath 路径前缀为绝对路径

变更文件(12 modified + 3 new):
- cmd/server/main.go: 动态主题加载 + 运行时重载 + 动态静态服务
- internal/theme/discovery.go: 新增主题扫描
- internal/config/site_settings.go: ThemeName()
- internal/service/site_setting_service.go: ThemeReloader + UpdateTheme
- internal/controller/admin/: ISP 扩展 + GetThemes/UpdateTheme/ThemePage
- internal/controller/auth_controller.go: 准则路径动态化
- internal/router/: InjectThemeReloader + 新路由
- templates/: 外观主题页面 + JS + CSS + 子导航
This commit is contained in:
2026-06-30 17:43:08 +08:00
parent 3d69dae799
commit e7092254ab
14 changed files with 426 additions and 18 deletions

View File

@ -1,7 +1,12 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"metazone.cc/mce/internal/common"
@ -69,26 +74,49 @@ func main() {
r := gin.Default()
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变
common.ComputeAssetHashes("templates/MetaLab-2026")
// 站点设置管理器DB 持久化 + 内存缓存 — 必须在模板加载前初始化
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 读取当前主题名称
themeName := siteSettings.ThemeName()
log.Printf("当前主题: %s", themeName)
// 计算静态资源哈希
common.ComputeAssetHashes("templates/" + themeName)
// 模板加载(前端主题 + 管理后台)
tmpl, err := theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
)
tmpl, err := loadThemeTemplates(themeName)
if err != nil {
panic("加载模板失败: " + err.Error())
log.Fatalf("加载模板失败: %v", err)
}
r.SetHTMLTemplate(tmpl)
// 静态资源
r.Static("/static", "./templates/MetaLab-2026/static")
// 静态资源 — 前台使用动态主题目录
r.GET("/static/*filepath", func(c *gin.Context) {
serveDynamicStatic(c, "templates", siteSettings, c.Param("filepath"))
})
r.Static("/admin/static", "./templates/admin/static")
r.Static("/shared/static", "./templates/shared/static")
r.Static("/uploads/avatars", "./storage/uploads/avatars")
r.Static("/uploads/posts", "./storage/uploads/posts")
// 主题重载函数(切换主题后实时生效)
themeReloader := func(newTheme string) error {
log.Printf("切换主题: %s", newTheme)
// 更新静态资源哈希
common.ComputeAssetHashes("templates/" + newTheme)
// 重新加载模板
newTmpl, err := loadThemeTemplates(newTheme)
if err != nil {
return err
}
r.SetHTMLTemplate(newTmpl)
return nil
}
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
var redisClient *redis.Client
if cfg.Redis.Enabled {
@ -103,18 +131,15 @@ func main() {
log.Println("Redis 未启用,使用内存存储")
}
// 站点设置管理器DB 持久化 + 内存缓存)
siteSettings, err := config.NewSiteSettings(db)
if err != nil {
log.Fatalf("初始化站点设置失败: %v", err)
}
// 启动数据库健康检测(每 5s ping失败自动降级
middleware.StartDBHealthCheck(db)
// 路由注册
router.Setup(r, db, cfg, siteSettings, redisClient)
// 注入主题重载回调到站点设置 Controller
router.InjectThemeReloader(themeReloader)
// 定时发布调度器
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
@ -124,3 +149,41 @@ func main() {
log.Fatalf("服务启动失败: %v", err)
}
}
// loadThemeTemplates 加载指定主题的模板(前台 + 管理后台)
func loadThemeTemplates(themeName string) (*template.Template, error) {
return theme.LoadTemplates(
theme.TemplateRoot{Dir: "templates/" + themeName + "/html", Prefix: ""},
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
)
}
// serveDynamicStatic 根据当前站点主题动态提供静态资源
func serveDynamicStatic(c *gin.Context, baseDir string, ss *config.SiteSettings, file string) {
themeName := ss.ThemeName()
root := filepath.Join(baseDir, themeName, "static")
// 清理路径并拼接
cleanFile := filepath.Clean(file)
fullPath := filepath.Join(root, cleanFile)
// 安全检查:防止路径遍历逃逸出主题目录
absRoot, err := filepath.Abs(root)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
absPath, err := filepath.Abs(fullPath)
if err != nil || !strings.HasPrefix(absPath, absRoot+string(os.PathSeparator)) && absPath != absRoot {
c.AbortWithStatus(http.StatusNotFound)
return
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
c.AbortWithStatus(http.StatusNotFound)
return
}
http.ServeFile(c.Writer, c.Request, fullPath)
}