新增功能:
- 主题发现模块:扫描 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:
@ -190,3 +190,8 @@ func (s *SiteSettings) GuidelinesTimer() int {
|
||||
func (s *SiteSettings) IsMaintenanceEnabled() bool {
|
||||
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
|
||||
}
|
||||
|
||||
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:4 个方法)
|
||||
// siteSettingUseCase SiteSettingController 对 SiteSettingService 的最小依赖(ISP:5 个方法)
|
||||
type siteSettingUseCase interface {
|
||||
GetSettings() map[string]string
|
||||
UpdateSetting(key, value string) error
|
||||
UpdateBoolSetting(key string, value bool) error
|
||||
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
|
||||
UpdateTheme(themeName string, reload service.ThemeReloader) error
|
||||
}
|
||||
|
||||
// auditStatusProvider 审核控制器所需的用户信息查询(ISP)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"metazone.cc/mce/internal/common"
|
||||
"metazone.cc/mce/internal/service"
|
||||
"metazone.cc/mce/internal/theme"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -14,6 +15,7 @@ import (
|
||||
type SiteSettingController struct {
|
||||
settingService siteSettingUseCase
|
||||
defaults *service.AuditDefaults
|
||||
themeReload service.ThemeReloader // 主题重载回调(切换后实时生效)
|
||||
}
|
||||
|
||||
// NewSiteSettingController 构造函数
|
||||
@ -21,6 +23,11 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi
|
||||
return &SiteSettingController{settingService: settingService, defaults: defaults}
|
||||
}
|
||||
|
||||
// SetThemeReloader 注入主题重载回调(main.go 中设置)
|
||||
func (sc *SiteSettingController) SetThemeReloader(reload service.ThemeReloader) {
|
||||
sc.themeReload = reload
|
||||
}
|
||||
|
||||
// SiteSettingsPage 旧站点设置页 — 301 重定向到品牌页
|
||||
func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) {
|
||||
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 获取所有站点设置
|
||||
func (sc *SiteSettingController) GetSettings(c *gin.Context) {
|
||||
all := sc.settingService.GetSettings()
|
||||
@ -146,3 +163,60 @@ func (sc *SiteSettingController) GetBoolSetting(c *gin.Context) {
|
||||
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 != "" {
|
||||
guidelines = template.HTML(guidelinesHTML) //nolint:gosec // 准则内容由管理员配置,属信任输入
|
||||
} else {
|
||||
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||
content, err := theme.LoadContent("templates/" + ac.siteSettings.ThemeName() + "/guidelines.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||
return
|
||||
|
||||
@ -36,6 +36,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
middleware.RequirePageRole(model.RoleOwner))
|
||||
adminPages.GET("/settings/content", d.siteSettingCtrl.ContentPage,
|
||||
middleware.RequirePageRole(model.RoleOwner))
|
||||
adminPages.GET("/settings/theme", d.siteSettingCtrl.ThemePage,
|
||||
middleware.RequirePageRole(model.RoleOwner))
|
||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||
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("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
||||
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
||||
siteSettingsAPI.GET("/themes", d.siteSettingCtrl.GetThemes)
|
||||
siteSettingsAPI.PUT("/theme", d.siteSettingCtrl.UpdateTheme)
|
||||
}
|
||||
|
||||
// --- 帖子管理 API(admin+) ---
|
||||
|
||||
@ -3,13 +3,25 @@ package router
|
||||
|
||||
import (
|
||||
"metazone.cc/mce/internal/config"
|
||||
adminCtrl "metazone.cc/mce/internal/controller/admin"
|
||||
"metazone.cc/mce/internal/middleware"
|
||||
"metazone.cc/mce/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 保存站点设置 Controller 引用,供 InjectThemeReloader 使用
|
||||
var globalSiteSettingCtrl *adminCtrl.SiteSettingController
|
||||
|
||||
// InjectThemeReloader 注入主题重载回调(main.go 在路由注册后调用,使主题切换实时生效)
|
||||
func InjectThemeReloader(reload service.ThemeReloader) {
|
||||
if globalSiteSettingCtrl != nil {
|
||||
globalSiteSettingCtrl.SetThemeReloader(reload)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup 注册所有路由
|
||||
func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) {
|
||||
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)
|
||||
|
||||
// 保存站点设置 Controller 引用
|
||||
globalSiteSettingCtrl = deps.siteSettingCtrl
|
||||
|
||||
// 维护模式中间件(全局,在路由匹配之前)
|
||||
r.Use(deps.maintenanceMdw.Handler())
|
||||
|
||||
|
||||
@ -80,3 +80,17 @@ func (s *SiteSettingService) GetBoolSetting(key string, defaultVal bool) bool {
|
||||
func ParseBool(v string) (bool, error) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user