新增功能:
- 主题发现模块:扫描 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:
@ -1,7 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
@ -69,26 +74,49 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// 计算静态资源哈希(用于缓存破坏,文件不变哈希不变)
|
// 站点设置管理器(DB 持久化 + 内存缓存 — 必须在模板加载前初始化)
|
||||||
common.ComputeAssetHashes("templates/MetaLab-2026")
|
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(
|
tmpl, err := loadThemeTemplates(themeName)
|
||||||
theme.TemplateRoot{Dir: "templates/MetaLab-2026/html", Prefix: ""},
|
|
||||||
theme.TemplateRoot{Dir: "templates/admin/html", Prefix: "admin/"},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("加载模板失败: " + err.Error())
|
log.Fatalf("加载模板失败: %v", err)
|
||||||
}
|
}
|
||||||
r.SetHTMLTemplate(tmpl)
|
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("/admin/static", "./templates/admin/static")
|
||||||
r.Static("/shared/static", "./templates/shared/static")
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
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 自动降级,恢复后自动切回)
|
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
||||||
var redisClient *redis.Client
|
var redisClient *redis.Client
|
||||||
if cfg.Redis.Enabled {
|
if cfg.Redis.Enabled {
|
||||||
@ -103,18 +131,15 @@ func main() {
|
|||||||
log.Println("Redis 未启用,使用内存存储")
|
log.Println("Redis 未启用,使用内存存储")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 站点设置管理器(DB 持久化 + 内存缓存)
|
|
||||||
siteSettings, err := config.NewSiteSettings(db)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("初始化站点设置失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动数据库健康检测(每 5s ping,失败自动降级)
|
// 启动数据库健康检测(每 5s ping,失败自动降级)
|
||||||
middleware.StartDBHealthCheck(db)
|
middleware.StartDBHealthCheck(db)
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg, siteSettings, redisClient)
|
router.Setup(r, db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 注入主题重载回调到站点设置 Controller
|
||||||
|
router.InjectThemeReloader(themeReloader)
|
||||||
|
|
||||||
// 定时发布调度器
|
// 定时发布调度器
|
||||||
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
scheduler.StartPublishScheduler(repository.NewPostRepo(db), 1*time.Minute)
|
||||||
|
|
||||||
@ -124,3 +149,41 @@ func main() {
|
|||||||
log.Fatalf("服务启动失败: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -190,3 +190,8 @@ func (s *SiteSettings) GuidelinesTimer() int {
|
|||||||
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
||||||
return s.GetBool("maintenance.enabled", false)
|
return s.GetBool("maintenance.enabled", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeName 当前选用的主题目录名,默认 "MetaLab-2026"
|
||||||
|
func (s *SiteSettings) ThemeName() string {
|
||||||
|
return s.Get("site.theme", "MetaLab-2026")
|
||||||
|
}
|
||||||
|
|||||||
@ -23,12 +23,13 @@ type auditUseCase interface {
|
|||||||
Reject(reviewerID uint, submissionID uint, reason string) error
|
Reject(reviewerID uint, submissionID uint, reason string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:4 个方法)
|
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:5 个方法)
|
||||||
type siteSettingUseCase interface {
|
type siteSettingUseCase interface {
|
||||||
GetSettings() map[string]string
|
GetSettings() map[string]string
|
||||||
UpdateSetting(key, value string) error
|
UpdateSetting(key, value string) error
|
||||||
UpdateBoolSetting(key string, value bool) error
|
UpdateBoolSetting(key string, value bool) error
|
||||||
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
||||||
|
UpdateTheme(themeName string, reload service.ThemeReloader) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
|
"metazone.cc/mce/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
type SiteSettingController struct {
|
type SiteSettingController struct {
|
||||||
settingService siteSettingUseCase
|
settingService siteSettingUseCase
|
||||||
defaults *service.AuditDefaults
|
defaults *service.AuditDefaults
|
||||||
|
themeReload service.ThemeReloader // 主题重载回调(切换后实时生效)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSiteSettingController 构造函数
|
// NewSiteSettingController 构造函数
|
||||||
@ -21,6 +23,11 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
|
|||||||
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetThemeReloader 注入主题重载回调(main.go 中设置)
|
||||||
|
func (sc *SiteSettingController) SetThemeReloader(reload service.ThemeReloader) {
|
||||||
|
sc.themeReload = reload
|
||||||
|
}
|
||||||
|
|
||||||
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
|
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
|
||||||
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
||||||
c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
|
c.Redirect(http.StatusMovedPermanently, "/admin/settings/brand")
|
||||||
@ -66,6 +73,16 @@ func (sc *SiteSettingController) ContentPage(c *gin.Context) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemePage 外观主题设置页
|
||||||
|
func (sc *SiteSettingController) ThemePage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/site-settings/theme.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "站点设置 - 外观主题",
|
||||||
|
"ExtraCSS": "/admin/static/css/site-settings.css",
|
||||||
|
"ExtraJS": "/admin/static/js/site-settings-theme.js",
|
||||||
|
"ActiveSetting": "theme",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// GetSettings 获取所有站点设置
|
// GetSettings 获取所有站点设置
|
||||||
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
||||||
all := sc.settingService.GetSettings()
|
all := sc.settingService.GetSettings()
|
||||||
@ -146,3 +163,60 @@ func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
|
|||||||
common.Ok(c, gin.H{"value": d})
|
common.Ok(c, gin.H{"value": d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetThemes 获取所有可用主题列表
|
||||||
|
func (sc *SiteSettingController) GetThemes(c *gin.Context) {
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if themes == nil {
|
||||||
|
themes = []theme.ThemeInfo{}
|
||||||
|
}
|
||||||
|
// 读取当前主题
|
||||||
|
all := sc.settingService.GetSettings()
|
||||||
|
currentTheme := "MetaLab-2026"
|
||||||
|
if t, ok := all["site.theme"]; ok && t != "" {
|
||||||
|
currentTheme = t
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"themes": themes,
|
||||||
|
"current": currentTheme,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTheme 切换主题
|
||||||
|
func (sc *SiteSettingController) UpdateTheme(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Theme string `json:"theme" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证主题是否存在
|
||||||
|
themes, err := theme.DiscoverThemes("templates")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取主题列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, t := range themes {
|
||||||
|
if t.Dir == req.Theme {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
common.Error(c, http.StatusBadRequest, "主题不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.settingService.UpdateTheme(req.Theme, sc.themeReload); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "主题切换失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "主题已切换")
|
||||||
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
|||||||
if guidelinesHTML != "" {
|
if guidelinesHTML != "" {
|
||||||
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
||||||
} else {
|
} else {
|
||||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
content, err := theme.LoadContent("templates/" + ac.siteSettings.ThemeName() + "/guidelines.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -36,6 +36,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
|
adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
|
||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/settings/theme", d.siteSettingCtrl.ThemePage,
|
||||||
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
@ -70,6 +72,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting)
|
siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting)
|
||||||
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
||||||
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
||||||
|
siteSettingsAPI.GET("/themes", d.siteSettingCtrl.GetThemes)
|
||||||
|
siteSettingsAPI.PUT("/theme", d.siteSettingCtrl.UpdateTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 帖子管理 API(admin+) ---
|
// --- 帖子管理 API(admin+) ---
|
||||||
|
|||||||
@ -3,13 +3,25 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"metazone.cc/mce/internal/config"
|
"metazone.cc/mce/internal/config"
|
||||||
|
adminCtrl "metazone.cc/mce/internal/controller/admin"
|
||||||
"metazone.cc/mce/internal/middleware"
|
"metazone.cc/mce/internal/middleware"
|
||||||
|
"metazone.cc/mce/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 保存站点设置 Controller 引用,供 InjectThemeReloader 使用
|
||||||
|
var globalSiteSettingCtrl *adminCtrl.SiteSettingController
|
||||||
|
|
||||||
|
// InjectThemeReloader 注入主题重载回调(main.go 在路由注册后调用,使主题切换实时生效)
|
||||||
|
func InjectThemeReloader(reload service.ThemeReloader) {
|
||||||
|
if globalSiteSettingCtrl != nil {
|
||||||
|
globalSiteSettingCtrl.SetThemeReloader(reload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// Setup 注册所有路由
|
||||||
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
|
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
@ -18,6 +30,9 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.
|
|||||||
|
|
||||||
deps := buildDeps(db, cfg, siteSettings, redisClient)
|
deps := buildDeps(db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
|
// 保存站点设置 Controller 引用
|
||||||
|
globalSiteSettingCtrl = deps.siteSettingCtrl
|
||||||
|
|
||||||
// 维护模式中间件(全局,在路由匹配之前)
|
// 维护模式中间件(全局,在路由匹配之前)
|
||||||
r.Use(deps.maintenanceMdw.Handler())
|
r.Use(deps.maintenanceMdw.Handler())
|
||||||
|
|
||||||
|
|||||||
@ -80,3 +80,17 @@ func (s *SiteSettingService) GetBoolSetting(key string, defaultVal bool) bool {
|
|||||||
func ParseBool(v string) (bool, error) {
|
func ParseBool(v string) (bool, error) {
|
||||||
return strconv.ParseBool(v)
|
return strconv.ParseBool(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeReloader 主题重载回调:切换主题后实时生效
|
||||||
|
type ThemeReloader func(themeName string) error
|
||||||
|
|
||||||
|
// UpdateTheme 切换主题并触发重载
|
||||||
|
func (s *SiteSettingService) UpdateTheme(themeName string, reload ThemeReloader) error {
|
||||||
|
if err := s.settings.Set("site.theme", themeName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reload != nil {
|
||||||
|
return reload(themeName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
52
internal/theme/discovery.go
Normal file
52
internal/theme/discovery.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ThemeInfo 描述一个已安装的主题。
|
||||||
|
type ThemeInfo struct {
|
||||||
|
Dir string `json:"dir"` // 目录名(如 "MetaLab-2026")
|
||||||
|
Name string `json:"name"` // 主题名称
|
||||||
|
DisplayName string `json:"display_name"` // 展示名称
|
||||||
|
Version string `json:"version"` // 版本号
|
||||||
|
Author string `json:"author"` // 作者
|
||||||
|
Description string `json:"description"` // 描述
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverThemes 扫描 templates/ 目录,返回所有包含 theme.json 的主题列表。
|
||||||
|
func DiscoverThemes(rootDir string) ([]ThemeInfo, error) {
|
||||||
|
entries, err := os.ReadDir(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes []ThemeInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
themeJSON := filepath.Join(rootDir, entry.Name(), "theme.json")
|
||||||
|
data, err := os.ReadFile(themeJSON) //nolint:gosec // 内部主题文件,非用户输入
|
||||||
|
if err != nil {
|
||||||
|
continue // 无 theme.json 则跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ThemeInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info.Dir = entry.Name()
|
||||||
|
themes = append(themes, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按名称排序,保证稳定展示
|
||||||
|
sort.Slice(themes, func(i, j int) bool {
|
||||||
|
return themes[i].Name < themes[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return themes, nil
|
||||||
|
}
|
||||||
@ -80,6 +80,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
{{template "settings/_footer.html" .}}
|
{{template "settings/_footer.html" .}}
|
||||||
<script src="/static/js/settings-account.js?v={{assetV "/static/js/settings-account.js"}}" nonce="{{.CSPNonce}}"></script>
|
<script src="/static/js/settings-account.js?v={{assetV "/static/js/settings-account.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
|
|||||||
@ -3,4 +3,5 @@
|
|||||||
<a href="/admin/settings/security" class="subnav-item {{if eq .ActiveSetting "security"}}active{{end}}">安全与维护</a>
|
<a href="/admin/settings/security" class="subnav-item {{if eq .ActiveSetting "security"}}active{{end}}">安全与维护</a>
|
||||||
<a href="/admin/settings/registration" class="subnav-item {{if eq .ActiveSetting "registration"}}active{{end}}">注册策略</a>
|
<a href="/admin/settings/registration" class="subnav-item {{if eq .ActiveSetting "registration"}}active{{end}}">注册策略</a>
|
||||||
<a href="/admin/settings/content" class="subnav-item {{if eq .ActiveSetting "content"}}active{{end}}">审核与内容</a>
|
<a href="/admin/settings/content" class="subnav-item {{if eq .ActiveSetting "content"}}active{{end}}">审核与内容</a>
|
||||||
|
<a href="/admin/settings/theme" class="subnav-item {{if eq .ActiveSetting "theme"}}active{{end}}">外观主题</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
templates/admin/html/site-settings/theme.html
Normal file
44
templates/admin/html/site-settings/theme.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>站点设置</h1>
|
||||||
|
<p class="page-desc">管理与切换站点外观主题</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "admin/site-settings/_menu.html" .}}
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-title">主题选择</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">当前主题</span>
|
||||||
|
<span class="setting-desc">切换主题后立即生效,无需重启服务</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-input-wrap">
|
||||||
|
<select id="theme-selector" class="setting-select">
|
||||||
|
<option value="">加载中...</option>
|
||||||
|
</select>
|
||||||
|
<button id="theme-save-btn" class="btn btn-sm btn-primary" disabled>保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">主题信息</span>
|
||||||
|
<span class="setting-desc">选中主题的详细描述</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-info-detail" id="theme-info">
|
||||||
|
<span class="setting-value">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="theme-status" class="settings-section" style="display:none">
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="setting-item">
|
||||||
|
<span id="theme-status-msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
|
<script src="/admin/static/js/site-settings-theme.js?v={{assetV "/admin/static/js/site-settings-theme.js"}}" nonce="{{.CSPNonce}}"></script>
|
||||||
@ -123,3 +123,30 @@
|
|||||||
.toggle-switch input:disabled + .toggle-slider {
|
.toggle-switch input:disabled + .toggle-slider {
|
||||||
opacity: 0.4; cursor: not-allowed;
|
opacity: 0.4; cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- 下拉选择器 + 保存按钮 --- */
|
||||||
|
.setting-select {
|
||||||
|
padding: 6px 10px; border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; font-size: 13px; min-width: 220px;
|
||||||
|
outline: none; background: #fff; cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.setting-select:focus { border-color: var(--primary); }
|
||||||
|
.btn-save-select { min-width: 56px; text-align: center; }
|
||||||
|
.btn-save-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* --- 主题信息详情 --- */
|
||||||
|
.setting-info-detail {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
font-size: 13px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 主题状态提示 --- */
|
||||||
|
.status-success .section-body {
|
||||||
|
background: #e8f5e9; border-left: 3px solid #4caf50;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
.status-error .section-body {
|
||||||
|
background: #fce4ec; border-left: 3px solid #f44336;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|||||||
109
templates/admin/static/js/site-settings-theme.js
Normal file
109
templates/admin/static/js/site-settings-theme.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/* =====================================================
|
||||||
|
MetaLab 管理面板 - 主题切换
|
||||||
|
立即生效,无需重启
|
||||||
|
===================================================== */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var selector = document.getElementById('theme-selector');
|
||||||
|
var saveBtn = document.getElementById('theme-save-btn');
|
||||||
|
var infoEl = document.getElementById('theme-info');
|
||||||
|
var statusSection = document.getElementById('theme-status');
|
||||||
|
var statusMsg = document.getElementById('theme-status-msg');
|
||||||
|
var themes = [];
|
||||||
|
var currentTheme = '';
|
||||||
|
|
||||||
|
async function loadThemes() {
|
||||||
|
try {
|
||||||
|
var res = await api.get('/api/admin/site-settings/themes');
|
||||||
|
if (!res.success) {
|
||||||
|
showToast('加载主题列表失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themes = res.data.themes || [];
|
||||||
|
currentTheme = res.data.current || 'MetaLab-2026';
|
||||||
|
|
||||||
|
// 填充下拉
|
||||||
|
selector.innerHTML = '';
|
||||||
|
themes.forEach(function(t) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = t.dir;
|
||||||
|
opt.textContent = t.display_name || t.name;
|
||||||
|
if (t.dir === currentTheme) {
|
||||||
|
opt.selected = true;
|
||||||
|
updateInfo(t);
|
||||||
|
}
|
||||||
|
selector.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
|
||||||
|
// 选择变化时更新信息
|
||||||
|
selector.addEventListener('change', function() {
|
||||||
|
var selected = selector.value;
|
||||||
|
var found = themes.find(function(t) { return t.dir === selected; });
|
||||||
|
if (found) {
|
||||||
|
updateInfo(found);
|
||||||
|
}
|
||||||
|
saveBtn.disabled = (selected === currentTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
saveBtn.addEventListener('click', function() {
|
||||||
|
applyTheme(selector.value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showToast('加载主题列表失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInfo(theme) {
|
||||||
|
var parts = [];
|
||||||
|
if (theme.display_name) parts.push(theme.display_name);
|
||||||
|
if (theme.version) parts.push('v' + theme.version);
|
||||||
|
if (theme.author) parts.push('作者:' + theme.author);
|
||||||
|
if (theme.description) parts.push(theme.description);
|
||||||
|
infoEl.innerHTML = '<span class="setting-value">' + escapeHtml(parts.join(' · ')) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTheme(themeDir) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '切换中...';
|
||||||
|
showStatus('正在切换主题...', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await api.put('/api/admin/site-settings/theme', { theme: themeDir });
|
||||||
|
if (res.success) {
|
||||||
|
currentTheme = themeDir;
|
||||||
|
saveBtn.textContent = '已生效';
|
||||||
|
showToast('主题已切换,即时生效', 'success');
|
||||||
|
showStatus('✅ 主题已切换为:<strong>' + themeDir + '</strong>,所有访客可见新主题', 'success');
|
||||||
|
} else {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
showToast(res.message || '切换失败', 'error');
|
||||||
|
showStatus('❌ 切换失败:' + (res.message || '未知错误'), 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = '保存';
|
||||||
|
showToast('切换失败', 'error');
|
||||||
|
showStatus('❌ 切换失败,请重试', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
statusSection.style.display = 'block';
|
||||||
|
statusSection.className = 'settings-section';
|
||||||
|
if (type === 'success') {
|
||||||
|
statusSection.classList.add('status-success');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
statusSection.classList.add('status-error');
|
||||||
|
}
|
||||||
|
statusMsg.innerHTML = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemes();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user